/** * PDF Studio — unified PDF workspace (edit, annotate, merge, split, export). * URL hash #r=mode overrides slug default (Smallpdf-style). */ import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.5.136/pdf.min.mjs'; import { PDFDocument, rgb, StandardFonts, degrees } from 'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.esm.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.5.136/pdf.worker.min.mjs'; const root = document.getElementById('pdfStudioRoot'); if (!root) { throw new Error('PDF Studio root not found'); } const studioPage = document.getElementById('pdfStudioPage') || root.closest('.pdf-studio-page'); const $ = (id) => document.getElementById(id); const slug = root.dataset.slug || 'edit-pdf'; const slugMode = root.dataset.defaultMode || 'edit'; const toolUrlBase = root.dataset.toolUrl || (typeof window.siteUrl === 'function' ? window.siteUrl('tool/') : ((typeof window.SITE_URL !== 'undefined' ? window.SITE_URL : '') + '/tool/')); const HASH_MODES = new Set([ 'edit', 'annotate', 'merge', 'split', 'organize', 'rotate', 'watermark', 'pages', 'compress', 'protect', 'redact', 'sign', 'export-images', 'export-text', 'compare', 'stamp', 'images', 'crop', ]); const protectApiUrl = root.dataset.protectApi || (typeof window.siteUrl === 'function' ? window.siteUrl('api/protect-pdf.php') : ((typeof window.SITE_URL !== 'undefined' ? window.SITE_URL : '') + '/api/protect-pdf.php')); let pageRailGen = 0; let pageSortable = null; let organizeThumbsSortable = null; let pageRailJustDragged = false; let mergeListSortable = null; let pageReorderBusy = false; function parseHashMode() { const h = (location.hash || '').replace(/^#/, ''); const m = h.match(/(?:^|[&;])r=([a-z0-9-]+)/i) || h.match(/^r=([a-z0-9-]+)/i); const mode = m ? m[1].toLowerCase() : ''; return HASH_MODES.has(mode) ? mode : ''; } let activeMode = parseHashMode() || slugMode; if (activeMode === 'annotate') { /* annotate uses editor with highlight tools */ } const state = { pdfJsDoc: null, pdfBytes: null, pageCount: 0, page: 1, scale: 0.65, tool: 'select', layers: {}, pageDims: {}, pdfRuns: {}, selectedId: null, drag: null, rectDraw: null, penPath: null, nextId: 1, mergeFiles: [], }; const pdfCanvas = $('psPdfCanvas'); const overlay = $('psOverlay'); const EDITOR_MODES = new Set(['edit', 'annotate', 'redact', 'sign', 'stamp']); const ACTION_MODES = new Set(['merge', 'split', 'organize', 'rotate', 'watermark', 'pages', 'compress', 'protect', 'compare', 'export-images', 'images', 'stamp']); const RESIZABLE_TYPES = new Set(['image', 'signature', 'cover', 'redact', 'highlight', 'sticky', 'nativeText', 'text', 'pen']); const SELECTION_CHROME_TYPES = new Set([ 'image', 'signature', 'text', 'nativeText', 'sticky', 'cover', 'redact', 'highlight', 'pen', ]); const COLORABLE_TYPES = new Set(['text', 'nativeText', 'sticky', 'cover', 'redact', 'highlight', 'pen']); const HANDLE_R = 14; const SEL_SCALE_STEP = 1.1; const DEFAULT_ZOOM = 1; const ZOOM_MIN = 0.4; const ZOOM_MAX = 3; const imageElCache = new Map(); let zoomUserAdjusted = false; let overlayRedrawRaf = 0; let pinchStartDist = 0; let pinchStartScale = 1; let pinchLiveFactor = 1; let pinchActive = false; let pageRenderTask = null; let pageRenderGen = 0; let pageRenderRunning = false; let pageRenderQueued = false; let docPointerBound = false; let inlineEditingId = null; let inlineEditSkipBlur = false; let overlayTouchIdle = null; let overlayTouchPending = null; function isRenderCancelledError(err) { if (!err) return false; const name = String(err.name || ''); const msg = String(err.message || ''); return name === 'RenderingCancelledException' || /cancel/i.test(msg) || /Cannot use the same canvas/i.test(msg); } async function cancelPageRender() { const task = pageRenderTask; pageRenderTask = null; if (!task) return; try { task.cancel(); } catch (_) { /* ignore */ } try { await task.promise; } catch (_) { /* cancelled */ } } /** * Public entry — coalesces concurrent calls into one exclusive render. * Fixes pdf.js: "Cannot use the same canvas during multiple render() operations". */ async function renderPage() { pageRenderQueued = true; pageRenderGen += 1; await cancelPageRender(); if (pageRenderRunning) return; pageRenderRunning = true; try { while (pageRenderQueued) { pageRenderQueued = false; const gen = pageRenderGen; try { await renderPageExclusive(gen); } catch (err) { if (!isRenderCancelledError(err)) console.error('renderPage', err); } if (gen !== pageRenderGen) pageRenderQueued = true; } } finally { pageRenderRunning = false; // Race: another caller may have queued while we were releasing the lock if (pageRenderQueued) { queueMicrotask(() => { renderPage(); }); } } } async function renderPageExclusive(gen) { if (!state.pdfJsDoc || !pdfCanvas) return; if (gen !== pageRenderGen) return; clearStageLiveZoom(); const pageNum = state.page; const page = await state.pdfJsDoc.getPage(pageNum); if (gen !== pageRenderGen) return; const vp1 = page.getViewport({ scale: 1 }); state.pageDims[pageNum] = { w: vp1.width, h: vp1.height }; applyResponsiveZoom(); if (gen !== pageRenderGen) return; // Wait one frame if layout not ready (avoid overlapping retry renders) const col = getCanvasScrollEl(); if (col && col.clientWidth < 40) { await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); if (gen !== pageRenderGen) return; applyResponsiveZoom(); } const scale = state.scale; const viewport = page.getViewport({ scale }); const ctx = pdfCanvas.getContext('2d'); if (!ctx) return; // Reset canvas — must happen only when no render is active await cancelPageRender(); if (gen !== pageRenderGen) return; pdfCanvas.width = Math.max(1, Math.floor(viewport.width)); pdfCanvas.height = Math.max(1, Math.floor(viewport.height)); pdfCanvas.style.width = pdfCanvas.width + 'px'; pdfCanvas.style.height = pdfCanvas.height + 'px'; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height); const task = page.render({ canvasContext: ctx, viewport, intent: 'display', }); pageRenderTask = task; try { await task.promise; } catch (err) { if (isRenderCancelledError(err) || gen !== pageRenderGen) return; throw err; } finally { if (pageRenderTask === task) pageRenderTask = null; } if (gen !== pageRenderGen || state.page !== pageNum) return; syncOverlaySize(); await new Promise((r) => requestAnimationFrame(r)); if (gen !== pageRenderGen) return; syncOverlaySize(); try { await extractPdfTextRuns(page, viewport); } catch (e) { console.warn('Text extract skipped', e); } if (gen !== pageRenderGen) return; redrawOverlay(); syncSelectionChrome(); if ($('psPageLbl')) $('psPageLbl').textContent = state.page + ' / ' + state.pageCount; if ($('psZoomLabel')) $('psZoomLabel').textContent = Math.round(state.scale * 100) + '%'; if ($('psSignPage')) $('psSignPage').value = String(state.page); updatePageRailActive(); updatePageActionButtons(); syncEditorScrollMode(); syncOverlayTouchMode(); } function toastOk(m) { if (typeof toastr !== 'undefined') toastr.success(m); } function toastErr(m) { if (typeof toastr !== 'undefined') toastr.error(m); } function toastWarn(m) { if (typeof toastr !== 'undefined') toastr.warning(m); } function setUploadLoading(on, message = 'Processing…') { const drop = $('psDropZone'); const overlay = $('psDropLoading'); const sel = $('psSelectBtn'); const txt = $('psDropLoadingText'); if (txt && message) txt.textContent = message; if (drop) drop.classList.toggle('is-loading', !!on); if (overlay) overlay.classList.toggle('d-none', !on); if (sel) { sel.classList.toggle('is-loading', !!on); sel.disabled = !!on; const idle = sel.querySelector('.ps-btn-idle'); const load = sel.querySelector('.ps-btn-loading'); const loadTxt = sel.querySelector('.ps-loading-text'); if (idle) idle.classList.toggle('d-none', !!on); if (load) load.classList.toggle('d-none', !on); if (loadTxt && message) loadTxt.textContent = message; } const input = $('psFileInput'); if (input) input.disabled = !!on; } function setExportLoading(on, message = 'Processing…') { const btn = $('psExportBtn'); const t = $('psExportLoadingText'); if (t && message) t.textContent = message; if (btn) { btn.classList.toggle('is-loading', !!on); btn.disabled = !!on; } } function setButtonLoading(btn, on, message = 'Processing…') { if (!btn) return; if (on) { if (!btn.dataset.psOrigHtml) btn.dataset.psOrigHtml = btn.innerHTML; btn.disabled = true; btn.classList.add('is-loading'); btn.innerHTML = `${message}`; } else { btn.disabled = false; btn.classList.remove('is-loading'); if (btn.dataset.psOrigHtml) { btn.innerHTML = btn.dataset.psOrigHtml; delete btn.dataset.psOrigHtml; } } } async function withStudioLoading(message, fn, targets = {}) { const { upload = false, export: exp = false, canvas = false, button = null, silent = false } = targets; if (upload) setUploadLoading(true, message); if (exp) setExportLoading(true, message); if (canvas) setCanvasBusy(true, message); if (button) setButtonLoading(button, true, message); try { return await fn(); } catch (e) { console.error(e); if (!silent) toastErr(e?.message || 'Operation failed'); throw e; } finally { if (upload) setUploadLoading(false); if (exp) setExportLoading(false); if (canvas) setCanvasBusy(false); if (button) setButtonLoading(button, false); } } function layerKey(p) { return String(p); } function getItems(p) { const k = layerKey(p); if (!state.layers[k]) state.layers[k] = []; return state.layers[k]; } function genId() { return 'o' + state.nextId++; } function rgbPdf(r, g, b) { return rgb(r / 255, g / 255, b / 255); } function winAnsiSafe(str) { const s = String(str ?? ''); let out = ''; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c < 9 || (c > 10 && c < 13) || (c > 14 && c < 32) || c === 127) continue; out += c <= 255 ? s[i] : '?'; } return out.length ? out : ' '; } function clampRectToPage(x, y, w, h, pw, ph) { let L = x, B = y, W = w, H = h; if (L < 0) { W += L; L = 0; } if (B < 0) { H += B; B = 0; } if (L + W > pw) W = pw - L; if (B + H > ph) H = ph - B; if (W < 0.5 || H < 0.5) return null; return { x: L, y: B, width: W, height: H }; } function drawTextSafe(page, rawText, opts) { const t = winAnsiSafe(rawText); try { page.drawText(t, opts); } catch { try { page.drawText(t.replace(/[^\x20-\x7E]/g, '?'), opts); } catch { page.drawText('.', { ...opts, size: Math.min(opts.size || 12, 8) }); } } } function hexToRgb(hex) { const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(String(hex || '').trim()); return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : { r: 30, g: 58, b: 95 }; } function rgbToHex(r, g, b) { return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join(''); } function defaultRgbForType(type) { if (type === 'cover') return { r: 255, g: 255, b: 255 }; if (type === 'redact') return { r: 15, g: 15, b: 15 }; if (type === 'highlight') return { r: 250, g: 204, b: 21 }; if (type === 'sticky') return { r: 66, g: 32, b: 6 }; if (type === 'pen') return { r: 30, g: 41, b: 59 }; return { r: 30, g: 58, b: 95 }; } function isColorableLayer(it) { return Boolean(it && COLORABLE_TYPES.has(it.type)); } function layerRgb(it) { if (!it) return defaultRgbForType('text'); if (it.r != null && it.g != null && it.b != null) { return { r: it.r, g: it.g, b: it.b }; } return defaultRgbForType(it.type); } function syncOverlaySize() { if (!pdfCanvas || !overlay) return; // Keep overlay bitmap identical to PDF canvas bitmap if (overlay.width !== pdfCanvas.width) overlay.width = pdfCanvas.width; if (overlay.height !== pdfCanvas.height) overlay.height = pdfCanvas.height; // Match CSS display size to the PDF canvas *rendered* size (after max-width scaling) const dw = Math.max(1, pdfCanvas.clientWidth || pdfCanvas.offsetWidth || pdfCanvas.width); const dh = Math.max(1, pdfCanvas.clientHeight || pdfCanvas.offsetHeight || pdfCanvas.height); overlay.style.width = dw + 'px'; overlay.style.height = dh + 'px'; overlay.style.left = '0px'; overlay.style.top = '0px'; syncSelectionChrome(); } /** * Map a rectangle in overlay bitmap pixels → CSS px relative to #psStage. * Uses live getBoundingClientRect so chrome stays locked to drawn content. */ function overlayRectToStage(r) { const stage = $('psStage'); if (!stage || !overlay.width || !overlay.height) { return { left: r.x, top: r.y, width: r.w, height: r.h, sx: 1, sy: 1 }; } const o = overlay.getBoundingClientRect(); const s = stage.getBoundingClientRect(); const sx = o.width / overlay.width; const sy = o.height / overlay.height; return { left: (o.left - s.left) + r.x * sx, top: (o.top - s.top) + r.y * sy, width: Math.max(1, r.w * sx), height: Math.max(1, r.h * sy), sx, sy, }; } function selectionStageScale() { if (!overlay.width || !overlay.height) return { sx: 1, sy: 1 }; const o = overlay.getBoundingClientRect(); const sx = o.width / overlay.width; const sy = o.height / overlay.height; return { sx: Number.isFinite(sx) && sx > 0 ? sx : 1, sy: Number.isFinite(sy) && sy > 0 ? sy : 1, }; } function clampZoom(scale) { return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(scale * 100) / 100)); } function getSelectedLayer() { if (!state.selectedId) return null; return getItems(state.page).find((x) => x.id === state.selectedId) || null; } function cloneLayerItem(it) { const copy = JSON.parse(JSON.stringify(it)); copy.id = genId(); if (copy.points?.length) { copy.points = copy.points.map((p) => ({ x: p.x + 14, y: p.y + 14 })); } else { copy.nx = Math.min(0.92, (copy.nx ?? 0) + 0.02); copy.ny = Math.min(0.92, (copy.ny ?? 0) + 0.02); } return copy; } function deleteSelectedLayer() { if (!state.selectedId) return; if (inlineEditingId === state.selectedId) stopInlineTextEdit(false); state.layers[layerKey(state.page)] = getItems(state.page).filter((x) => x.id !== state.selectedId); state.selectedId = null; updatePropPanel(); redrawOverlay(); } async function clearLoadedPdfAndPickNew() { if (!state.pdfBytes && !state.pdfJsDoc) return; if (!confirm('Remove this PDF? Unsaved edits will be lost.')) return; if (inlineEditingId) stopInlineTextEdit(false); await cancelPageRender(); pageRenderGen += 1; if (state.pdfJsDoc?.destroy) { try { state.pdfJsDoc.destroy(); } catch (_) {} } state.pdfBytes = null; state.pdfJsDoc = null; state.pageCount = 0; state.page = 1; state.layers = {}; state.pageDims = {}; state.pdfRuns = {}; state.selectedId = null; state.nextId = 1; state.drag = null; state.rectDraw = null; state.penPath = null; zoomUserAdjusted = false; imageElCache.clear(); const list = $('psPagesRailList'); if (list) list.innerHTML = ''; if ($('psPageCountLbl')) $('psPageCountLbl').textContent = ''; $('psPanelEdit')?.classList.remove('is-pages-open'); syncPagesToggleUi(false); if (pdfCanvas) { const ctx = pdfCanvas.getContext('2d'); ctx?.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height); pdfCanvas.width = 1; pdfCanvas.height = 1; } if (overlay) { const octx = overlay.getContext('2d'); octx?.clearRect(0, 0, overlay.width, overlay.height); overlay.width = 1; overlay.height = 1; } $('psSelChrome')?.classList.add('d-none'); const input = $('psFileInput'); if (input) input.value = ''; setWorkspaceVisible(false); syncWorkspaceView(activeMode || slugMode); updatePropPanel(); updatePageActionButtons(); toastOk('PDF removed'); } function copySelectedLayer() { const it = getSelectedLayer(); if (!it) return; const dup = cloneLayerItem(it); getItems(state.page).push(dup); state.selectedId = dup.id; setTool('select'); updatePropPanel(); redrawOverlay(); toastOk('Copied'); } function scaleSelectedLayer(factor) { const it = getSelectedLayer(); if (!it || factor <= 0) return; const cw = overlay.width; const ch = overlay.height; if (it.type === 'pen' && it.points?.length) { const b = penBounds(it.points); const cx = b.x + b.w / 2; const cy = b.y + b.h / 2; const nb = { x: cx - (b.w * factor) / 2, y: cy - (b.h * factor) / 2, w: Math.max(12, b.w * factor), h: Math.max(12, b.h * factor), }; it.points = scalePenPoints(it.points, b, nb); if (it.width) it.width = Math.max(1, Math.min(12, it.width * factor)); } else if (it.type === 'text' || it.type === 'nativeText') { ensureItemBox(it, cw, ch); const cx = it.nx + it.nw / 2; const cy = it.ny + it.nh / 2; it.fontPt = Math.max(6, Math.min(220, (it.fontPt || 12) * factor)); fitTextItemBox(it); it.nx = Math.max(0, Math.min(1 - it.nw, cx - it.nw / 2)); it.ny = Math.max(0, Math.min(1 - it.nh, cy - it.nh / 2)); } else { ensureItemBox(it, cw, ch); const cx = it.nx + it.nw / 2; const cy = it.ny + it.nh / 2; it.nw = Math.max(0.03, Math.min(1, it.nw * factor)); it.nh = Math.max(0.03, Math.min(1, it.nh * factor)); it.nx = Math.max(0, Math.min(1 - it.nw, cx - it.nw / 2)); it.ny = Math.max(0, Math.min(1 - it.nh, cy - it.nh / 2)); if (it.fontPt) { it.fontPt = Math.max(6, Math.min(220, it.fontPt * factor)); } } syncSelectionChrome(); scheduleOverlayRedraw(); if (inlineEditingId === it.id) positionInlineEditor(it); } function handleHitSize() { const { sx, sy } = selectionStageScale(); const visual = Math.min(sx, sy) || 1; const target = isMobileStudioLayout() ? (window.innerWidth <= 480 ? 28 : 24) : 18; return Math.max(HANDLE_R, target / visual); } function drawResizeHandles(ctx, r) { const hs = isMobileStudioLayout() ? 10 : 7; const corners = [ [r.x, r.y], [r.x + r.w / 2, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h / 2], [r.x + r.w, r.y + r.h], [r.x + r.w / 2, r.y + r.h], [r.x, r.y + r.h], [r.x, r.y + r.h / 2], ]; ctx.lineWidth = 2; for (const [cx, cy] of corners) { ctx.beginPath(); ctx.arc(cx, cy, hs, 0, Math.PI * 2); ctx.fillStyle = '#2563eb'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.stroke(); } } function shouldUseHtmlSelectionChrome() { const it = getSelectedLayer(); return Boolean(it && state.tool === 'select' && SELECTION_CHROME_TYPES.has(it.type)); } function scheduleOverlayRedraw() { if (overlayRedrawRaf) return; overlayRedrawRaf = requestAnimationFrame(() => { overlayRedrawRaf = 0; redrawOverlay(); }); } function flushOverlayRedraw() { if (overlayRedrawRaf) { cancelAnimationFrame(overlayRedrawRaf); overlayRedrawRaf = 0; } redrawOverlay(); } function setChromeDragClass(on, resizing = false) { const chrome = $('psSelChrome'); if (!chrome) return; chrome.classList.toggle('is-dragging', !!on); chrome.classList.toggle('is-resizing', !!(on && resizing)); } function syncSelectionChrome() { const chrome = $('psSelChrome'); if (!chrome) return; const it = getSelectedLayer(); const show = Boolean( it && state.tool === 'select' && SELECTION_CHROME_TYPES.has(it.type) && overlay.width && overlay.height ); if (!show) { chrome.classList.add('d-none'); chrome.setAttribute('aria-hidden', 'true'); if (inlineEditingId) stopInlineTextEdit(true); return; } // Ensure overlay CSS size matches PDF canvas before measuring const dw = pdfCanvas?.clientWidth; const dh = pdfCanvas?.clientHeight; if (dw && dh) { if (overlay.style.width !== dw + 'px') overlay.style.width = dw + 'px'; if (overlay.style.height !== dh + 'px') overlay.style.height = dh + 'px'; } const r = itemScreenRect(it, overlay.width, overlay.height); const box = overlayRectToStage(r); chrome.style.left = `${box.left}px`; chrome.style.top = `${box.top}px`; chrome.style.width = `${box.width}px`; chrome.style.height = `${box.height}px`; const bar = chrome.querySelector('.ps-sel-chrome-bar'); if (bar) bar.classList.toggle('is-bottom', box.top < 36); chrome.classList.remove('d-none'); chrome.setAttribute('aria-hidden', 'false'); syncColorDotFromSelection(); if (inlineEditingId === it.id) positionInlineEditor(it); } function startChromeResize(handle, clientX, clientY) { const it = getSelectedLayer(); if (!it || !handle) return; const cw = overlay.width; const ch = overlay.height; ensureItemBox(it, cw, ch); const { x, y } = canvasPoint({ clientX, clientY }); const orig = { nx: it.nx, ny: it.ny, nw: it.nw, nh: it.nh, fontPt: it.fontPt || 12 }; if (it.type === 'pen') { orig.bounds = penBounds(it.points); orig.points = it.points.map((p) => ({ x: p.x, y: p.y })); } setChromeDragClass(true, true); state.drag = { mode: 'resize', handle, id: it.id, startX: x, startY: y, orig, }; } function ensureDocPointerListeners() { if (docPointerBound) return; docPointerBound = true; document.addEventListener('pointermove', onDocPointerMove, { passive: false }); document.addEventListener('pointerup', onDocPointerUp, { passive: false }); document.addEventListener('pointercancel', onDocPointerUp, { passive: false }); } function onDocPointerMove(ev) { if (pinchActive) return; if (!state.drag && !state.rectDraw && !state.penPath) return; // Ignore secondary touch pointers while dragging with primary if (ev.pointerType === 'touch' && !ev.isPrimary && state.drag) return; if (state.drag || state.rectDraw || state.penPath) { ev.preventDefault(); onOverlayMove({ clientX: ev.clientX, clientY: ev.clientY }); } } function onDocPointerUp(ev) { if (!state.drag && !state.rectDraw && !state.penPath) return; if (ev.pointerType === 'touch' && !ev.isPrimary && state.drag) return; onOverlayUp(); } function initSelectionChrome() { const chrome = $('psSelChrome'); if (!chrome || chrome.dataset.bound) return; chrome.dataset.bound = '1'; ensureDocPointerListeners(); const stop = (e) => { e.preventDefault(); e.stopPropagation(); }; $('psSelCopy')?.addEventListener('click', (e) => { stop(e); if (inlineEditingId) stopInlineTextEdit(true); const sel = getSelectedLayer(); if (!sel) return; copySelectedLayer(); }); $('psSelDelete')?.addEventListener('click', (e) => { stop(e); if (inlineEditingId) stopInlineTextEdit(false); const sel = getSelectedLayer(); if (!sel) return; deleteSelectedLayer(); toastOk('Removed'); }); const onColorWrapInteract = (e) => { e.stopPropagation(); inlineEditSkipBlur = true; setTimeout(() => { inlineEditSkipBlur = false; }, 250); }; $('psSelColor')?.addEventListener('input', (e) => { const sel = getSelectedLayer(); if (!sel || !isColorableLayer(sel)) return; const rgbC = hexToRgb(e.target.value); sel.r = rgbC.r; sel.g = rgbC.g; sel.b = rgbC.b; syncColorDotFromSelection(); if (inlineEditingId === sel.id) { const ed = getInlineEditor(); if (ed) ed.style.color = e.target.value; } scheduleOverlayRedraw(); }); $('psSelColor')?.addEventListener('change', (e) => { const sel = getSelectedLayer(); if (!sel || !isColorableLayer(sel)) return; const rgbC = hexToRgb(e.target.value); sel.r = rgbC.r; sel.g = rgbC.g; sel.b = rgbC.b; syncColorDotFromSelection(); scheduleOverlayRedraw(); }); $('psSelColor')?.addEventListener('click', (e) => e.stopPropagation()); $('psSelColor')?.addEventListener('pointerdown', (e) => e.stopPropagation()); document.querySelector('.ps-sel-color-wrap')?.addEventListener('pointerdown', onColorWrapInteract); document.querySelector('.ps-sel-color-wrap')?.addEventListener('click', onColorWrapInteract); const ed = getInlineEditor(); if (ed && !ed.dataset.bound) { ed.dataset.bound = '1'; ed.addEventListener('input', onInlineTextInput); ed.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); stopInlineTextEdit(true); } else if (e.key === 'Enter' && !e.shiftKey && getSelectedLayer()?.type !== 'sticky') { e.preventDefault(); stopInlineTextEdit(true); } }); ed.addEventListener('blur', () => { if (inlineEditSkipBlur) return; // Delay so toolbar color/± clicks can run first setTimeout(() => { if (inlineEditSkipBlur) return; if (document.activeElement === ed) return; if (inlineEditingId) stopInlineTextEdit(true); }, 120); }); ed.addEventListener('pointerdown', (e) => e.stopPropagation()); ed.addEventListener('mousedown', (e) => e.stopPropagation()); } // Keep toolbar clicks from closing editor / starting canvas drag chrome.querySelector('.ps-sel-chrome-bar')?.addEventListener('pointerdown', (e) => { e.stopPropagation(); inlineEditSkipBlur = true; setTimeout(() => { inlineEditSkipBlur = false; }, 200); }); chrome.querySelector('.ps-sel-chrome-bar')?.addEventListener('mousedown', (e) => e.stopPropagation()); chrome.querySelector('.ps-sel-chrome-bar')?.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true }); chrome.querySelectorAll('.ps-sel-handle').forEach((el) => { const onHandleDown = (e) => { stop(e); if (inlineEditingId) stopInlineTextEdit(true); if (state.tool !== 'select') setTool('select'); const pt = e.touches?.[0] || e; startChromeResize(el.dataset.handle, pt.clientX, pt.clientY); if (e.pointerId != null && el.setPointerCapture) { try { el.setPointerCapture(e.pointerId); } catch (_) {} } }; el.addEventListener('pointerdown', onHandleDown); el.addEventListener('mousedown', onHandleDown); el.addEventListener('touchstart', onHandleDown, { passive: false }); }); // Drag move from center pad (not from edge — edges are for resize handles) const onChromeMoveDown = (e) => { if (!e.target.closest('.ps-sel-move')) return; if (inlineEditingId) return; const it = getSelectedLayer(); if (!it || state.tool !== 'select') return; stop(e); const pt = e.touches?.[0] || e; const { x, y } = canvasPoint({ clientX: pt.clientX, clientY: pt.clientY }); const r = itemScreenRect(it, overlay.width, overlay.height); ensureItemBox(it, overlay.width, overlay.height); setChromeDragClass(true, false); const drag = { mode: 'move', id: it.id, ox: x - r.x, oy: y - r.y }; if (it.type === 'pen' && it.points?.length) { drag.penOrigPoints = it.points.map((p) => ({ x: p.x, y: p.y })); } state.drag = drag; }; chrome.addEventListener('pointerdown', onChromeMoveDown); chrome.addEventListener('mousedown', onChromeMoveDown); chrome.addEventListener('touchstart', onChromeMoveDown, { passive: false }); $('psCanvasCol')?.addEventListener('scroll', syncSelectionChrome, { passive: true }); window.addEventListener('resize', syncSelectionChrome); } function transformPdfjs(a, b) { return [ a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5], ]; } async function extractPdfTextRuns(page, viewport) { const tc = await page.getTextContent(); const runs = []; const cw = viewport.width; const ch = viewport.height; const base = viewport.transform || [1, 0, 0, 1, 0, 0]; for (const item of tc.items) { if (!('str' in item) || item.str === '') continue; const t = item.transform; const tx = pdfjsLib.Util?.transform ? pdfjsLib.Util.transform(viewport.transform, t) : transformPdfjs(base, t); const fontHeight = Math.hypot(tx[2], tx[3]) || 10; const left = tx[4]; const top = tx[5] - fontHeight * 0.9; const w = Math.max((item.width || 0) * fontHeight, fontHeight * 0.35 * [...item.str].length, 4); const h = Math.max(fontHeight * 1.28, 6); if (left + w < -2 || left > cw + 2 || top + h < -2 || top > ch + 2) continue; runs.push({ left, top, w, h, str: item.str, fontPt: Math.max(5, Math.min(96, fontHeight / state.scale)) }); } state.pdfRuns[state.page] = runs; } function applyResponsiveZoom() { const dim = state.pageDims[state.page]; const col = document.querySelector('.ps-canvas-col'); if (!dim || !col || zoomUserAdjusted) { if (!zoomUserAdjusted) state.scale = DEFAULT_ZOOM; return; } const availW = Math.max(140, (col.clientWidth || col.offsetWidth || 320) - 24); const fitW = (availW / Math.max(dim.w, 1)) * 0.96; if (window.innerWidth < 992) { state.scale = clampZoom(Math.min(2, Math.max(0.45, fitW))); } else { state.scale = clampZoom(Math.min(1.4, Math.max(0.5, fitW))); } } function syncEditorScrollMode() { const center = document.querySelector('.ps-editor-center'); if (center) center.classList.toggle('is-multi-page', state.pageCount > 1); } function textMetrics(it, cw, ch) { const dim = state.pageDims[state.page]; const fontPt = it.fontPt || 12; const fontPx = fontPt * (cw / Math.max(dim?.w || 612, 1)); if (it.nw != null && it.nh != null) { return { fontPx, w: Math.max(24, it.nw * cw), h: Math.max(16, it.nh * ch) }; } return { fontPx, w: Math.max(24, fontPx * 0.55 * (it.text || 'M').length), h: fontPx * 1.25 }; } /** Keep text/sticky font size matched to the selection box while resizing. */ function syncTextFontFromBox(it, cw, ch, orig, handle) { if (!it || it.type !== 'sticky') return; if (!cw || !ch || it.nh == null || it.nw == null) return; const dim = state.pageDims[state.page] || { w: 612, h: 792 }; const basePt = orig?.fontPt || it.fontPt || 12; const lines = Math.max(1, String(it.text || 'M').split('\n').length); if (handle === 'e' || handle === 'w') { const ratio = it.nw / Math.max(orig?.nw || it.nw, 1e-6); it.fontPt = Math.max(6, Math.min(220, basePt * ratio)); return; } const fontPx = (it.nh * ch) / (1.25 * lines); it.fontPt = Math.max(6, Math.min(220, fontPx * (dim.w / cw))); } /** * Resize text so font + selection border grow/shrink together and stay the same size. * Scale is taken from the drag, then the border is fitted tightly around the text. */ function resizeTextLayerMatched(it, orig, handle, dx, dy, cw, ch) { const min = 0.02; let nw = orig.nw; let nh = orig.nh; if (handle === 'se') { nw = orig.nw + dx; nh = orig.nh + dy; } else if (handle === 'sw') { nw = orig.nw - dx; nh = orig.nh + dy; } else if (handle === 'ne') { nw = orig.nw + dx; nh = orig.nh - dy; } else if (handle === 'nw') { nw = orig.nw - dx; nh = orig.nh - dy; } else if (handle === 'n') { nh = orig.nh - dy; } else if (handle === 's') { nh = orig.nh + dy; } else if (handle === 'e') { nw = orig.nw + dx; } else if (handle === 'w') { nw = orig.nw - dx; } nw = Math.max(min, nw); nh = Math.max(min, nh); let scale; if (handle === 'e' || handle === 'w') { scale = nw / Math.max(orig.nw, 1e-6); } else if (handle === 'n' || handle === 's') { scale = nh / Math.max(orig.nh, 1e-6); } else { // Corners: uniform scale from both axes so text + border stay matched scale = Math.sqrt( Math.max(0.01, (nw / Math.max(orig.nw, 1e-6)) * (nh / Math.max(orig.nh, 1e-6))) ); } scale = Math.max(0.2, Math.min(10, scale)); it.fontPt = Math.max(6, Math.min(220, (orig.fontPt || 12) * scale)); const right = orig.nx + orig.nw; const bottom = orig.ny + orig.nh; const cx = orig.nx + orig.nw / 2; const cy = orig.ny + orig.nh / 2; fitTextItemBox(it); // border snaps to rendered text size if (handle === 'se') { it.nx = orig.nx; it.ny = orig.ny; } else if (handle === 'sw') { it.nx = right - it.nw; it.ny = orig.ny; } else if (handle === 'ne') { it.nx = orig.nx; it.ny = bottom - it.nh; } else if (handle === 'nw') { it.nx = right - it.nw; it.ny = bottom - it.nh; } else if (handle === 'e') { it.nx = orig.nx; it.ny = cy - it.nh / 2; } else if (handle === 'w') { it.nx = right - it.nw; it.ny = cy - it.nh / 2; } else if (handle === 's') { it.nx = cx - it.nw / 2; it.ny = orig.ny; } else if (handle === 'n') { it.nx = cx - it.nw / 2; it.ny = bottom - it.nh; } it.nx = Math.max(0, Math.min(1 - it.nw, it.nx)); it.ny = Math.max(0, Math.min(1 - it.nh, it.ny)); } function penBounds(points) { if (!points?.length) return { x: 0, y: 0, w: 0, h: 0 }; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const p of points) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } const pad = 6; return { x: minX - pad, y: minY - pad, w: Math.max(12, maxX - minX + pad * 2), h: Math.max(12, maxY - minY + pad * 2), }; } function scalePenPoints(points, oldB, newB) { const sx = oldB.w > 0 ? newB.w / oldB.w : 1; const sy = oldB.h > 0 ? newB.h / oldB.h : 1; return points.map((p) => ({ x: newB.x + (p.x - oldB.x) * sx, y: newB.y + (p.y - oldB.y) * sy, })); } function ensureItemBox(it, cw, ch) { if (it.nw != null && it.nh != null) return; const r = itemScreenRect(it, cw, ch); it.nx = r.x / cw; it.ny = r.y / ch; it.nw = r.w / cw; it.nh = r.h / ch; } function boundsFromResize(origB, handle, dx, dy) { const min = 12; let x = origB.x; let y = origB.y; let w = origB.w; let h = origB.h; if (handle === 'se') { w = Math.max(min, origB.w + dx); h = Math.max(min, origB.h + dy); } else if (handle === 'sw') { w = Math.max(min, origB.w - dx); h = Math.max(min, origB.h + dy); x = origB.x + (origB.w - w); } else if (handle === 'ne') { w = Math.max(min, origB.w + dx); h = Math.max(min, origB.h - dy); y = origB.y + (origB.h - h); } else if (handle === 'nw') { w = Math.max(min, origB.w - dx); h = Math.max(min, origB.h - dy); x = origB.x + (origB.w - w); y = origB.y + (origB.h - h); } else if (handle === 'n') { h = Math.max(min, origB.h - dy); y = origB.y + (origB.h - h); } else if (handle === 's') { h = Math.max(min, origB.h + dy); } else if (handle === 'e') { w = Math.max(min, origB.w + dx); } else if (handle === 'w') { w = Math.max(min, origB.w - dx); x = origB.x + (origB.w - w); } return { x, y, w, h }; } function loadImageFromDataUrl(dataUrl) { if (imageElCache.has(dataUrl)) { const cached = imageElCache.get(dataUrl); if (cached.complete) return Promise.resolve(cached); } return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { imageElCache.set(dataUrl, img); resolve(img); }; img.onerror = reject; img.src = dataUrl; }); } function itemScreenRect(it, cw, ch) { if (it.type === 'pen' && it.points?.length) { return penBounds(it.points); } if (it.type === 'sticky') { return { x: it.nx * cw, y: it.ny * ch, w: Math.max(24, (it.nw || 0.22) * cw), h: Math.max(20, (it.nh || 0.1) * ch), }; } if (it.type === 'text' || it.type === 'nativeText') { if (it.nw != null && it.nh != null) { return { x: it.nx * cw, y: it.ny * ch, w: it.nw * cw, h: it.nh * ch }; } const m = textMetrics(it, cw, ch); return { x: it.nx * cw, y: it.ny * ch, w: m.w, h: m.h }; } return { x: it.nx * cw, y: it.ny * ch, w: it.nw * cw, h: it.nh * ch }; } function hitResizeHandle(mx, my, it) { if (!it || !RESIZABLE_TYPES.has(it.type)) return null; const cw = overlay.width; const ch = overlay.height; const r = itemScreenRect(it, cw, ch); const hs = handleHitSize(); const pts = { nw: [r.x, r.y], n: [r.x + r.w / 2, r.y], ne: [r.x + r.w, r.y], e: [r.x + r.w, r.y + r.h / 2], se: [r.x + r.w, r.y + r.h], s: [r.x + r.w / 2, r.y + r.h], sw: [r.x, r.y + r.h], w: [r.x, r.y + r.h / 2], }; let best = null; let bestDist = Infinity; for (const [name, [cx, cy]] of Object.entries(pts)) { const d = Math.hypot(mx - cx, my - cy); if (d <= hs && d < bestDist) { bestDist = d; best = name; } } return best; } async function addImageLayerFromDataUrl(dataUrl, layerType = 'image') { if (!overlay.width && state.pdfJsDoc) await renderPage(); const img = await loadImageFromDataUrl(dataUrl); const dim = state.pageDims[state.page] || { w: 612, h: 792 }; const cw = overlay.width || dim.w * state.scale; const ch = overlay.height || dim.h * state.scale; const aspect = img.width / Math.max(img.height, 1); let nw = 0.35; let nh = nw * (cw / ch) / aspect; if (nh > 0.45) { nh = 0.45; nw = nh * aspect * (ch / cw); } const it = { id: genId(), type: layerType, dataUrl, nx: 0.32, ny: 0.34, nw, nh, }; getItems(state.page).push(it); state.selectedId = it.id; setTool('select'); updatePropPanel(); redrawOverlay(); return it; } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } function openEditAfterTool(message) { showModePanel('edit'); setTool('select'); if (message) toastOk(message); } function hitTest(mx, my) { const items = getItems(state.page); const cw = overlay.width; const ch = overlay.height; for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; if (it.type === 'text' || it.type === 'sticky') { const { w, h } = textMetrics(it, cw, ch); const x = it.nx * cw; const y = it.ny * ch; if (mx >= x && mx <= x + w && my >= y && my <= y + h) return it; } else if (['cover', 'redact', 'nativeText', 'highlight', 'image', 'signature'].includes(it.type)) { const r = itemScreenRect(it, cw, ch); if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return it; } else if (it.type === 'pen' && it.points?.length) { const r = penBounds(it.points); if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return it; } } return null; } function hitPdfRun(mx, my) { const runs = state.pdfRuns[state.page] || []; for (let i = runs.length - 1; i >= 0; i--) { const r = runs[i]; if (mx >= r.left && mx <= r.left + r.w && my >= r.top && my <= r.top + r.h) return r; } return null; } function centerInNativeText(cx, cy) { const items = getItems(state.page); const cw = overlay.width; const ch = overlay.height; for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; if (it.type !== 'nativeText') continue; const x = it.nx * cw; const y = it.ny * ch; const w = it.nw * cw; const h = it.nh * ch; if (cx >= x && cx <= x + w && cy >= y && cy <= y + h) return it; } return null; } function redrawOverlay() { const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); const cw = overlay.width; const ch = overlay.height; const dim = state.pageDims[state.page]; if (!dim) return; const htmlChrome = shouldUseHtmlSelectionChrome(); for (const it of getItems(state.page)) { const isSel = it.id === state.selectedId; if (it.type === 'cover') { const r = itemScreenRect(it, cw, ch); const c = layerRgb(it); ctx.fillStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 0.97)`; ctx.fillRect(r.x, r.y, r.w, r.h); if (!isSel || !htmlChrome) { ctx.strokeStyle = isSel ? '#4f46e5' : 'rgba(148, 163, 184, 0.5)'; ctx.lineWidth = isSel ? 2 : 1; ctx.strokeRect(r.x, r.y, r.w, r.h); } } else if (it.type === 'highlight') { const r = itemScreenRect(it, cw, ch); const c = layerRgb(it); ctx.fillStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 0.45)`; ctx.fillRect(r.x, r.y, r.w, r.h); if (isSel && !htmlChrome) { ctx.strokeStyle = '#ca8a04'; ctx.lineWidth = 2; ctx.strokeRect(r.x, r.y, r.w, r.h); } } else if (it.type === 'redact') { const r = itemScreenRect(it, cw, ch); const c = layerRgb(it); ctx.fillStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 0.94)`; ctx.fillRect(r.x, r.y, r.w, r.h); if (isSel && !htmlChrome) { ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 2; ctx.strokeRect(r.x, r.y, r.w, r.h); } } else if (it.type === 'pen' && it.points?.length > 1) { const c = layerRgb(it); ctx.strokeStyle = `rgb(${c.r},${c.g},${c.b})`; ctx.lineWidth = it.width || 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(it.points[0].x, it.points[0].y); for (let i = 1; i < it.points.length; i++) ctx.lineTo(it.points[i].x, it.points[i].y); ctx.stroke(); if (isSel && !htmlChrome) { const r = penBounds(it.points); ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 2; ctx.strokeRect(r.x, r.y, r.w, r.h); } } else if (it.type === 'sticky') { const rSticky = itemScreenRect(it, cw, ch); const x = rSticky.x; const y = rSticky.y; const w = rSticky.w; const h = rSticky.h; ctx.fillStyle = 'rgba(254, 240, 138, 0.95)'; ctx.fillRect(x, y, w, h); ctx.strokeStyle = '#eab308'; ctx.strokeRect(x, y, w, h); if (!(it._editing && inlineEditingId === it.id)) { const { fontPx: stickyFont } = textMetrics(it, cw, ch); ctx.fillStyle = '#422006'; ctx.font = `${Math.max(10, stickyFont)}px ui-sans-serif, sans-serif`; wrapText(ctx, it.text || 'Note', x + 8, y + 18, w - 16, Math.max(12, stickyFont * 1.25)); } if (isSel && !htmlChrome) { ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 2; ctx.strokeRect(x, y, w, h); } } else if (it.type === 'text' || it.type === 'nativeText') { const { fontPx } = textMetrics(it, cw, ch); if (it.type === 'nativeText') { const pad = Math.max(2, fontPx * 0.12); ctx.fillStyle = '#fff'; ctx.fillRect(it.nx * cw - pad, it.ny * ch - pad, it.nw * cw + pad * 2, it.nh * ch + pad * 2); } if (!(it._editing && inlineEditingId === it.id)) { ctx.font = `${fontPx}px ui-sans-serif, sans-serif`; ctx.textBaseline = 'top'; ctx.fillStyle = `rgb(${it.r}, ${it.g}, ${it.b})`; ctx.fillText(it.text || '', it.nx * cw + 1, it.ny * ch + 1); } if (isSel && !htmlChrome) { const r = itemScreenRect(it, cw, ch); ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 2; ctx.strokeRect(r.x, r.y, r.w, r.h); } } else if ((it.type === 'image' || it.type === 'signature') && it.dataUrl) { const r = itemScreenRect(it, cw, ch); const img = imageElCache.get(it.dataUrl); if (img?.complete) { ctx.drawImage(img, r.x, r.y, r.w, r.h); } else { loadImageFromDataUrl(it.dataUrl).then(() => redrawOverlay()).catch(() => {}); } if (isSel && !htmlChrome) { ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 2; ctx.strokeRect(r.x, r.y, r.w, r.h); } } } syncSelectionChrome(); if (state.rectDraw) { const r = state.rectDraw; ctx.strokeStyle = '#4f46e5'; ctx.setLineDash([6, 4]); ctx.strokeRect(r.x, r.y, r.w, r.h); ctx.setLineDash([]); } if (state.penPath?.length > 1) { ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(state.penPath[0].x, state.penPath[0].y); for (let i = 1; i < state.penPath.length; i++) ctx.lineTo(state.penPath[i].x, state.penPath[i].y); ctx.stroke(); } } function wrapText(ctx, text, x, y, maxWidth, lineHeight = 16) { const words = String(text).split(/\s+/); let line = ''; let ly = y; for (const w of words) { const test = line ? line + ' ' + w : w; if (ctx.measureText(test).width > maxWidth && line) { ctx.fillText(line, x, ly); line = w; ly += lineHeight; } else line = test; } if (line) ctx.fillText(line, x, ly); } function updatePropPanel() { const sel = getItems(state.page).find((x) => x.id === state.selectedId); const empty = $('psPropEmpty'); const panel = $('psPropPanel'); const origWrap = $('psPropOrigWrap'); // Text / sticky edit inline on canvas — never show side property form if (!sel || ['text', 'nativeText', 'sticky', 'image', 'signature', 'pen', 'cover', 'redact', 'highlight'].includes(sel.type)) { if (empty) { empty.classList.add('d-none'); } if (panel) panel.classList.add('d-none'); if (origWrap) origWrap.classList.add('d-none'); syncColorDotFromSelection(); syncPropsDock(); return; } if (empty) { empty.classList.remove('d-none'); empty.textContent = 'Select an object or pick a tool.'; } if (panel) panel.classList.add('d-none'); if (origWrap) origWrap.classList.add('d-none'); syncPropsDock(); } function syncColorDotFromSelection() { const sel = getSelectedLayer(); const dot = $('psSelColorDot'); const input = $('psSelColor'); const wrap = document.querySelector('.ps-sel-color-wrap'); const show = isColorableLayer(sel); if (wrap) { wrap.classList.toggle('d-none', !show); const label = show && ['cover', 'redact', 'highlight', 'pen'].includes(sel.type) ? 'Color' : 'Text color'; wrap.title = label; wrap.setAttribute('aria-label', label); } if (!show || !sel) return; const c = layerRgb(sel); const hex = rgbToHex(c.r, c.g, c.b); if (dot) dot.style.background = hex; if (input && input.value !== hex) input.value = hex; } function isInlineEditing() { return Boolean(inlineEditingId); } function getInlineEditor() { return $('psInlineText'); } function stopInlineTextEdit(commit = true) { const ed = getInlineEditor(); const id = inlineEditingId; inlineEditingId = null; $('psSelChrome')?.classList.remove('is-editing-text'); if (!ed) return; ed.classList.add('d-none'); ed.blur(); if (!id) return; const it = getItems(state.page).find((x) => x.id === id); if (!it) return; if (commit) { const raw = String(ed.value || '').replace(/\r/g, ''); it.text = raw.trim() ? raw : ' '; fitTextItemBox(it); } delete it._editing; redrawOverlay(); syncSelectionChrome(); } function fitTextItemBox(it) { if (!it || !overlay.width) return; const cw = overlay.width; const ch = overlay.height; const dim = state.pageDims[state.page] || { w: 612, h: 792 }; const fontPx = (it.fontPt || 12) * (cw / dim.w); const lines = String(it.text || ' ').split('\n'); let maxChars = 1; lines.forEach((ln) => { maxChars = Math.max(maxChars, ln.length || 1); }); const w = Math.max(fontPx * 1.2, fontPx * 0.55 * maxChars + 8); const h = Math.max(fontPx * 1.25, fontPx * 1.25 * Math.max(1, lines.length) + 4); it.nw = Math.min(0.95, w / cw); it.nh = Math.min(0.95, h / ch); it.nx = Math.max(0, Math.min(1 - it.nw, it.nx)); it.ny = Math.max(0, Math.min(1 - it.nh, it.ny)); } function positionInlineEditor(it) { const ed = getInlineEditor(); if (!ed || !it) return; const r = itemScreenRect(it, overlay.width, overlay.height); const box = overlayRectToStage(r); const dim = state.pageDims[state.page] || { w: 612, h: 792 }; const fontPx = (it.fontPt || 12) * (overlay.width / dim.w) * box.sy; const color = rgbToHex(it.r ?? 30, it.g ?? 58, it.b ?? 95); ed.style.left = `${box.left}px`; ed.style.top = `${box.top}px`; ed.style.width = `${Math.max(48, box.width)}px`; ed.style.minHeight = `${Math.max(fontPx * 1.3, box.height)}px`; ed.style.fontSize = `${Math.max(10, fontPx)}px`; ed.style.lineHeight = '1.25'; ed.style.color = color; if (it.type === 'sticky') { ed.style.background = 'rgba(254, 243, 199, 0.96)'; } else { ed.style.background = 'rgba(255, 255, 255, 0.96)'; } } function startInlineTextEdit(it, opts = {}) { if (!it || !['text', 'nativeText', 'sticky'].includes(it.type)) return; const ed = getInlineEditor(); if (!ed) return; if (inlineEditingId && inlineEditingId !== it.id) { stopInlineTextEdit(true); } state.selectedId = it.id; setTool('select'); inlineEditingId = it.id; it._editing = true; const placeholderDefaults = ['New text', 'Sticky note']; let val = it.text || ''; if (opts.clearPlaceholder && placeholderDefaults.includes(val)) val = ''; ed.value = val; ed.placeholder = it.type === 'sticky' ? 'Sticky note…' : 'Type here…'; positionInlineEditor(it); ed.classList.remove('d-none'); $('psSelChrome')?.classList.add('is-editing-text'); syncSelectionChrome(); syncColorDotFromSelection(); redrawOverlay(); requestAnimationFrame(() => { ed.focus(); if (opts.selectAll !== false) { ed.select(); } else { const len = ed.value.length; ed.setSelectionRange(len, len); } }); } function onInlineTextInput() { const ed = getInlineEditor(); if (!ed || !inlineEditingId) return; const it = getItems(state.page).find((x) => x.id === inlineEditingId); if (!it) return; it.text = ed.value; fitTextItemBox(it); positionInlineEditor(it); syncSelectionChrome(); // Keep textarea height in sync ed.style.height = 'auto'; ed.style.height = `${Math.max(parseFloat(ed.style.minHeight) || 24, ed.scrollHeight)}px`; } function setTool(t) { state.tool = t; document.querySelectorAll('.ps-tool-btn').forEach((b) => { b.classList.toggle('active', b.dataset.tool === t); }); if (t === 'select') overlay.style.cursor = 'default'; else if (t === 'pdftext') overlay.style.cursor = 'text'; else if (t === 'image') { overlay.style.cursor = 'copy'; $('psEditorImageInput')?.click(); } else overlay.style.cursor = 'crosshair'; syncOverlayTouchMode(); syncPropsDock(); } function syncOverlayTouchMode() { const col = getCanvasScrollEl(); if (!col) return; const drawing = state.tool && state.tool !== 'select'; col.classList.toggle('is-drawing', drawing); if (!drawing && !state.drag && !state.rectDraw && !state.penPath) { col.classList.remove('is-touch-capture'); } } function setTouchCapture(on) { getCanvasScrollEl()?.classList.toggle('is-touch-capture', !!on); } function canvasPoint(ev) { const r = overlay.getBoundingClientRect(); const sx = overlay.width / r.width; const sy = overlay.height / r.height; return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy }; } const SIDEBAR_COLLAPSED_KEY = 'pdf-studio-sidebar-collapsed'; const STUDIO_EXPANDED_KEY = 'pdf-studio-expanded'; function setSidebarCollapsed(collapsed) { const layout = $('psLayout'); const sidebar = $('psSidebar'); if (!layout) return; layout.classList.toggle('is-sidebar-collapsed', collapsed); sidebar?.classList.toggle('is-collapsed', collapsed); ['psSidebarToggle', 'psSidebarMenuBtn'].forEach((id) => { const btn = $(id); const icon = $(id === 'psSidebarToggle' ? 'psSidebarToggleIcon' : 'psSidebarMenuBtnIcon'); if (btn) { btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); btn.title = collapsed ? 'Show tools menu' : 'Hide tools menu'; } if (icon) { icon.classList.toggle('fa-bars', collapsed); icon.classList.toggle('fa-xmark', !collapsed); } }); } function isStudioExpanded() { return studioPage?.classList.contains('is-expanded') || document.fullscreenElement === studioPage; } function updateExpandToggleUI(expanded) { const btn = $('psExpandToggle'); const icon = $('psExpandToggleIcon'); const label = $('psExpandToggleLabel'); if (btn) { btn.setAttribute('aria-pressed', expanded ? 'true' : 'false'); btn.title = expanded ? 'Exit fullscreen (Esc)' : 'Open fullscreen'; } if (icon) { icon.classList.toggle('fa-expand', !expanded); icon.classList.toggle('fa-compress', expanded); } if (label) label.textContent = expanded ? 'Exit' : 'Fullscreen'; } function applyExpandedState(expanded, save = true) { studioPage?.classList.toggle('is-expanded', expanded); root.classList.remove('is-expanded'); document.body.classList.toggle('pdf-studio-expanded-lock', expanded); updateExpandToggleUI(expanded); if (save) { try { localStorage.setItem(STUDIO_EXPANDED_KEY, expanded ? '1' : '0'); } catch (_) {} } clearTimeout(applyExpandedState._t); applyExpandedState._t = setTimeout(() => { syncMobileStickyOffsets(); if (state.pdfJsDoc) { zoomUserAdjusted = false; renderPage(); } }, 180); } async function setStudioExpanded(expanded, save = true) { if (expanded) { applyExpandedState(true, save); if (studioPage?.requestFullscreen) { try { if (document.fullscreenElement !== studioPage) await studioPage.requestFullscreen(); } catch (_) {} } } else { if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch (_) {} } applyExpandedState(false, save); } } function initExpandToggle() { const btn = $('psExpandToggle'); if (!btn) return; let expanded = false; try { expanded = localStorage.getItem(STUDIO_EXPANDED_KEY) === '1'; } catch (_) {} if (expanded) setStudioExpanded(true, false); else updateExpandToggleUI(false); btn.addEventListener('click', () => { setStudioExpanded(!isStudioExpanded()); }); document.addEventListener('fullscreenchange', () => { const native = document.fullscreenElement === studioPage; if (native) { applyExpandedState(true, false); return; } if (isStudioExpanded()) { applyExpandedState(false, true); } else { updateExpandToggleUI(false); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isStudioExpanded()) { setStudioExpanded(false); } }); } function initExportDropdown() { const btn = $('psExportBtn'); if (!btn || !window.bootstrap?.Dropdown) return; bootstrap.Dropdown.getOrCreateInstance(btn, { popperConfig(defaultBsPopperConfig) { return { ...defaultBsPopperConfig, strategy: 'fixed', modifiers: [ ...(defaultBsPopperConfig.modifiers || []), { name: 'preventOverflow', options: { boundary: document.documentElement } }, ], }; }, }); } function toggleSidebarCollapsed() { const collapsed = !$('psLayout')?.classList.contains('is-sidebar-collapsed'); setSidebarCollapsed(collapsed); try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch (_) {} } function initSidebarToggle() { let collapsed = true; try { const saved = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); if (saved !== null) collapsed = saved === '1'; } catch (_) {} setSidebarCollapsed(collapsed); $('psSidebarToggle')?.addEventListener('click', toggleSidebarCollapsed); $('psSidebarMenuBtn')?.addEventListener('click', toggleSidebarCollapsed); } function setWorkspaceVisible(on) { $('psUploadScreen').classList.toggle('d-none', on); $('psWorkspace').classList.toggle('d-none', !on); $('psExportBtn').disabled = !on; $('psAddFiles').classList.toggle('d-none', !on); $('psSidebarToggle')?.classList.toggle('d-none', !on); if ($('psDelSel')) $('psDelSel').disabled = !on; document.querySelector('.ps-header')?.classList.toggle('is-compact', !!on); if (typeof syncMobileStickyOffsets === 'function') { requestAnimationFrame(() => syncMobileStickyOffsets()); } } function isPdfFile(file) { if (!file) return false; if (file.type === 'application/pdf') return true; return /\.pdf$/i.test(String(file.name || '')); } function isMergeMode(mode = activeMode) { return mode === 'merge' || slugMode === 'merge' || slug === 'merge-pdf'; } function shouldShowEditorPanel(mode = activeMode) { if (state.pdfBytes) return true; if (EDIT_ONLY_MODES.has(mode)) return true; return false; } function syncMergeLandingLayout() { $('psPanelEdit')?.classList.remove('ps-merge-setup'); } function syncWorkspaceView(mode = activeMode) { if (state.pdfBytes) { setWorkspaceVisible(true); syncMergeLandingLayout(mode); return; } if (isMergeMode(mode)) { setWorkspaceVisible(false); syncMergeLandingLayout(mode); $('psMergeUploadPanel')?.classList.toggle('d-none', state.mergeFiles.length === 0); return; } const m = mode || slugMode; if (UPLOAD_LANDING_MODES.has(m) || UPLOAD_LANDING_MODES.has(slugMode)) { setWorkspaceVisible(false); } syncMergeLandingLayout(mode); } const ACTION_PANELS = { merge: 'psPanelMerge', split: 'psPanelSplit', organize: 'psPanelOrganize', rotate: 'psPanelRotate', watermark: 'psPanelWatermark', pages: 'psPanelPages', compress: 'psPanelCompress', protect: 'psPanelProtect', 'export-images': 'psPanelExportImages', compare: 'psPanelCompare', images: 'psPanelMerge', }; const EDIT_ONLY_MODES = new Set(['edit', 'annotate', 'redact', 'sign', 'export-text']); const UPLOAD_LANDING_MODES = new Set(['merge', 'split', 'organize', 'images', 'rotate', 'compress', 'pages', 'stamp', 'watermark']); const SLUG_PANEL_TITLES = { 'rotate-pdf-pages': { panel: 'psPanelRotate', title: 'Rotate pages' }, 'remove-pdf-pages': { panel: 'psPanelOrganize', title: 'Delete pages' }, 'extract-pdf-pages': { panel: 'psPanelSplit', title: 'Extract pages' }, 'rearrange-pdf-pages': { panel: 'psPanelOrganize', title: 'Organize pages' }, 'compress-pdf': { panel: 'psPanelCompress', title: 'Compress PDF' }, 'web-optimize-pdf': { panel: 'psPanelCompress', title: 'Compress PDF' }, 'flatten-pdf': { panel: 'psPanelCompress', title: 'Compress PDF' }, 'add-page-numbers': { panel: 'psPanelPages', title: 'Page numbers' }, 'pdf-overlay': { panel: 'psPanelWatermark', title: 'Stamp / overlay' }, 'add-watermark': { panel: 'psPanelWatermark', title: 'Watermark' }, }; function resolveActionPanelId(panelMode) { if (panelMode === 'organize' && slug === 'extract-pdf-pages') return 'psPanelSplit'; if (panelMode === 'stamp') return 'psPanelWatermark'; return ACTION_PANELS[panelMode] || null; } function updateSlugPanelTitle() { const meta = SLUG_PANEL_TITLES[slug]; if (!meta) return; const panel = $(meta.panel); if (!panel) return; const titleSpan = $('psOrganizePanelTitle'); if (titleSpan && meta.panel === 'psPanelOrganize') { titleSpan.textContent = meta.title; return; } const titleEl = panel.querySelector('.ps-right-sidebar-head h2 span, .p-4 h2 span'); if (titleEl) { titleEl.textContent = meta.title; return; } const head = panel.querySelector('.ps-right-sidebar-head h2, .p-4 h2, .h5'); if (head) { const icon = head.querySelector('i'); head.textContent = ''; if (icon) head.appendChild(icon); head.append(' ' + meta.title); } } function syncRightSidebar() { const signOn = activeMode === 'sign'; // Text/sticky use on-canvas inline edit — do not open properties sidebar const propsOpen = signOn; $('psRightProps')?.classList.toggle('d-none', !propsOpen); const actionOpen = Boolean( document.querySelector('#psRightPanels .ps-panel-action.ps-panel-right:not(.d-none)') ); const rightOpen = propsOpen || actionOpen; $('psRightSidebar')?.classList.toggle('d-none', !rightOpen); $('psPanelEdit')?.classList.toggle('has-right-panel', rightOpen); } function syncPropsDock() { syncRightSidebar(); } function showModePanel(mode) { activeMode = mode; document.querySelectorAll('.ps-nav-link').forEach((a) => { a.classList.toggle('active', a.dataset.mode === mode); }); let panelMode = mode; if (mode === 'organize' && slug === 'extract-pdf-pages') panelMode = 'split'; if (mode === 'stamp') panelMode = 'watermark'; document.querySelectorAll('#psRightPanels .ps-panel-action.ps-panel-right').forEach((el) => { el.classList.add('d-none'); }); const actionId = resolveActionPanelId(panelMode); if (actionId) $(actionId)?.classList.remove('d-none'); updateSlugPanelTitle(); const showEditor = shouldShowEditorPanel(mode); $('psPanelEdit')?.classList.toggle('d-none', !showEditor); syncMergeLandingLayout(mode); if (EDITOR_MODES.has(mode) || mode === 'annotate') { if (mode === 'annotate') setTool('highlight'); else if (mode === 'redact') setTool('redact'); else if (mode === 'sign') setTool('select'); else setTool('select'); } if (state.pageCount) { $('psSplitFrom').value = 1; $('psSplitTo').value = state.pageCount; buildPageThumbs(); } const signOn = mode === 'sign'; $('psPropSign')?.classList.toggle('d-none', !signOn); $('psPropMain')?.classList.toggle('d-none', signOn); if (signOn && state.pageCount) { $('psSignPage').value = String(state.page); } if (!state.pdfBytes) syncWorkspaceView(mode); syncRightSidebar(); } function getCanvasScrollEl() { return $('psCanvasCol'); } function resetCanvasScroll(pos = 'start') { const el = getCanvasScrollEl(); if (!el) return; el.style.scrollBehavior = 'auto'; el.scrollTop = pos === 'end' ? Math.max(0, el.scrollHeight - el.clientHeight) : 0; requestAnimationFrame(() => { el.style.scrollBehavior = ''; }); } async function gotoPage(n, opts = {}) { if (!state.pdfJsDoc || n < 1 || n > state.pageCount) return; if (n === state.page && !opts.force) { resetCanvasScroll(opts.scrollPos || 'start'); return; } const stage = $('psStage'); stage?.classList.add('is-turning'); state.page = n; state.selectedId = null; updatePropPanel(); await renderPage(); resetCanvasScroll(opts.scrollPos || 'start'); requestAnimationFrame(() => stage?.classList.remove('is-turning')); } function touchDistance(touches) { const a = touches[0]; const b = touches[1]; return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); } function setStageLiveZoom(factor) { const stage = $('psStage'); if (!stage) return; stage.style.transformOrigin = 'center top'; stage.style.transform = factor === 1 ? '' : `scale(${factor})`; } function clearStageLiveZoom() { const stage = $('psStage'); if (stage) stage.style.transform = ''; } function getStudioScrollParent() { if (isStudioExpanded() && studioPage) return studioPage; return null; } function scrollStudioBy(dy) { if (!dy) return; const el = getStudioScrollParent(); if (el) el.scrollTop += dy; else window.scrollBy(0, dy); } /** Pinch-to-zoom; one-finger drag pans/scrolls the preview (no page change). */ function bindCanvasGestures() { const col = getCanvasScrollEl(); if (!col || col.dataset.pinchBound) return; col.dataset.pinchBound = '1'; const onPinchStart = (ev) => { if (!ev.touches || ev.touches.length !== 2 || !state.pdfJsDoc) return; pinchActive = true; pinchStartDist = touchDistance(ev.touches) || 1; pinchStartScale = state.scale; pinchLiveFactor = 1; state.drag = null; state.rectDraw = null; state.penPath = null; overlayTouchIdle = null; overlayTouchPending = null; setTouchCapture(true); ev.preventDefault(); }; const onPinchMove = (ev) => { if (!pinchActive || !ev.touches || ev.touches.length !== 2) return; ev.preventDefault(); const dist = touchDistance(ev.touches); if (!dist || !pinchStartDist) return; const factor = dist / pinchStartDist; const next = clampZoom(pinchStartScale * factor); pinchLiveFactor = next / pinchStartScale; setStageLiveZoom(pinchLiveFactor); const lbl = $('psZoomLabel'); if (lbl) lbl.textContent = Math.round(next * 100) + '%'; }; const finishPinch = async () => { if (!pinchActive) return; pinchActive = false; const next = clampZoom(pinchStartScale * pinchLiveFactor); clearStageLiveZoom(); pinchStartDist = 0; pinchLiveFactor = 1; setTouchCapture(false); if (Math.abs(next - state.scale) < 0.01) { if ($('psZoomLabel')) $('psZoomLabel').textContent = Math.round(state.scale * 100) + '%'; return; } zoomUserAdjusted = true; state.scale = next; await renderPage(); }; const onPinchEnd = (ev) => { if (!pinchActive) return; if (ev.touches && ev.touches.length >= 2) return; finishPinch(); }; col.addEventListener('touchstart', onPinchStart, { passive: false, capture: true }); col.addEventListener('touchmove', onPinchMove, { passive: false, capture: true }); col.addEventListener('touchend', onPinchEnd, { passive: false, capture: true }); col.addEventListener('touchcancel', onPinchEnd, { passive: false, capture: true }); // After preview scroll ends, continue scrolling the page (or fullscreen shell) let chainLastY = 0; col.addEventListener('touchstart', (ev) => { if (ev.touches?.length === 1) chainLastY = ev.touches[0].clientY; }, { passive: true }); col.addEventListener('touchmove', (ev) => { if (!isMobileStudioLayout() || !ev.touches || ev.touches.length !== 1) return; if (pinchActive || state.drag || state.rectDraw || state.penPath) return; if (col.classList.contains('is-touch-capture') || col.classList.contains('is-drawing')) return; const y = ev.touches[0].clientY; const dy = chainLastY - y; chainLastY = y; if (!dy) return; const max = Math.max(0, col.scrollHeight - col.clientHeight); const atBottom = col.scrollTop >= max - 1; const atTop = col.scrollTop <= 1; if (max <= 2 || (dy > 0 && atBottom) || (dy < 0 && atTop)) { scrollStudioBy(dy); } }, { passive: true }); col.addEventListener('wheel', (ev) => { if (!isMobileStudioLayout()) return; if (pinchActive || state.drag || state.rectDraw || state.penPath) return; const max = Math.max(0, col.scrollHeight - col.clientHeight); const atBottom = col.scrollTop >= max - 1; const atTop = col.scrollTop <= 1; if ((ev.deltaY > 0 && atBottom) || (ev.deltaY < 0 && atTop) || max <= 2) { scrollStudioBy(ev.deltaY); ev.preventDefault(); } }, { passive: false }); } function syncMobileStickyOffsets() { const rootStyle = document.documentElement.style; if (!isMobileStudioLayout()) { rootStyle.removeProperty('--ps-site-nav-h'); rootStyle.removeProperty('--ps-sticky-under-header'); rootStyle.removeProperty('--ps-expanded-header-h'); // Desktop always shows pages rail $('psPanelEdit')?.classList.remove('is-pages-open'); syncPagesToggleUi(false); return; } const expanded = document.querySelector('.pdf-studio-page.is-expanded, .pdf-studio-page:fullscreen'); const nav = document.querySelector('.app-navbar.sticky-top, .navbar.sticky-top'); const header = document.querySelector('.ps-header'); const navH = expanded ? 0 : (nav ? Math.round(nav.getBoundingClientRect().height) : 0); const headerH = header ? Math.round(header.getBoundingClientRect().height) : 0; rootStyle.setProperty('--ps-site-nav-h', `${navH}px`); rootStyle.setProperty('--ps-sticky-under-header', `${navH + headerH}px`); rootStyle.setProperty('--ps-expanded-header-h', `${headerH}px`); syncPagesToggleUi($('psPanelEdit')?.classList.contains('is-pages-open')); } function syncPagesToggleUi(open) { const btn = $('psTogglePages'); if (!btn) return; btn.classList.toggle('is-active', !!open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.title = open ? 'Hide pages' : 'Show pages'; const label = btn.querySelector('.ps-pages-toggle-label'); if (label) label.textContent = open ? 'Hide' : 'Pages'; const rail = $('psPagesRail'); if (rail) rail.setAttribute('aria-hidden', open ? 'false' : 'true'); } function toggleMobilePagesRail(force) { const panel = $('psPanelEdit'); if (!panel) return; if (!isMobileStudioLayout()) { panel.classList.remove('is-pages-open'); syncPagesToggleUi(false); return; } const next = typeof force === 'boolean' ? force : !panel.classList.contains('is-pages-open'); panel.classList.toggle('is-pages-open', next); syncPagesToggleUi(next); if (next) { // Bring pages into view above the bottom action bar requestAnimationFrame(() => { $('psPagesRail')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); } } function updatePageRailActive() { document.querySelectorAll('.ps-page-rail-item').forEach((el) => { const p = Number(el.dataset.page || 0); el.classList.toggle('active', p === state.page); }); } function updatePageActionButtons() { const up = $('psPageUp'); const down = $('psPageDown'); const del = $('psPageDelete'); if (up) up.disabled = state.page <= 1 || state.pageCount < 2; if (down) down.disabled = state.page >= state.pageCount || state.pageCount < 2; if (del) del.disabled = state.pageCount <= 1; } function shiftLayerKeysInsertAfter(page1Based) { const next = {}; for (const [k, items] of Object.entries(state.layers)) { const n = parseInt(k, 10); if (Number.isNaN(n)) continue; next[n > page1Based ? String(n + 1) : k] = items; } state.layers = next; } function removeLayerPage(removed1Based) { const next = {}; for (const [k, items] of Object.entries(state.layers)) { const n = parseInt(k, 10); if (n === removed1Based) continue; next[String(n > removed1Based ? n - 1 : n)] = items; } state.layers = next; } function remapLayersByOrder(orderZeroBased) { const next = {}; orderZeroBased.forEach((oldIdx, newIdx) => { const src = state.layers[String(oldIdx + 1)]; if (src) next[String(newIdx + 1)] = src; }); state.layers = next; } function setCanvasBusy(on, message = 'Processing…') { const col = $('psCanvasCol'); const busy = $('psCanvasBusy'); const txt = $('psCanvasBusyText'); if (txt && message) txt.textContent = message; if (col) col.classList.toggle('is-busy', !!on); if (busy) busy.classList.toggle('d-none', !on); } async function applyPdfBytesSmooth(bytes, focusPage = 1, opts = {}) { const { preserveLayers = false, silent = true, refreshRail = true, busyMessage = 'Updating…' } = opts; setCanvasBusy(true, busyMessage); try { const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); state.pdfBytes = u8; await cancelPageRender(); pageRenderGen += 1; const oldDoc = state.pdfJsDoc; const newDoc = await pdfjsLib.getDocument({ data: u8.slice(0) }).promise; if (oldDoc?.destroy) { try { oldDoc.destroy(); } catch (_) {} } state.pdfJsDoc = newDoc; state.pageCount = newDoc.numPages; state.page = Math.max(1, Math.min(focusPage, state.pageCount)); state.pageDims = {}; state.pdfRuns = {}; if (!preserveLayers) { state.layers = {}; state.selectedId = null; state.nextId = 1; } await renderPage(); if (refreshRail) await refreshPageRail(); updatePageActionButtons(); updatePropPanel(); if (!silent) toastOk('Updated'); } catch (e) { console.error(e); toastErr('Update failed'); } finally { setCanvasBusy(false); } } async function reloadPdfDocument(pdf, focusPage, opts = {}) { const bytes = await pdf.save(); const target = Math.max(1, Math.min(focusPage ?? state.page, pdf.getPageCount())); await applyPdfBytesSmooth(bytes, target, { preserveLayers: true, silent: true, ...opts }); } async function buildPdfFromOrder(orderZeroBased) { const src = await PDFDocument.load(state.pdfBytes); const out = await PDFDocument.create(); const pages = await out.copyPages(src, orderZeroBased); pages.forEach((p) => out.addPage(p)); return out; } async function applyPageOrder(orderZeroBased, focusPage1) { if (pageReorderBusy) return; pageReorderBusy = true; try { remapLayersByOrder(orderZeroBased); const out = await buildPdfFromOrder(orderZeroBased); const bytes = await out.save(); await applyPdfBytesSmooth(bytes, focusPage1, { preserveLayers: true, silent: true }); } finally { pageReorderBusy = false; } } async function reorderPages(fromIdx, toIdx) { if (!state.pdfBytes || fromIdx === toIdx) return; const order = Array.from({ length: state.pageCount }, (_, i) => i); const [moved] = order.splice(fromIdx, 1); order.splice(toIdx, 0, moved); const focus = toIdx + 1; state.page = focus; await applyPageOrder(order, focus); } async function insertPagesAfter(afterPage1Based, helperDoc) { const src = await PDFDocument.load(state.pdfBytes); const out = await PDFDocument.create(); const helperIndices = helperDoc.getPageIndices(); for (let i = 0; i < src.getPageCount(); i++) { const [p] = await out.copyPages(src, [i]); out.addPage(p); if (i === afterPage1Based - 1 && helperIndices.length) { const inserted = await out.copyPages(helperDoc, helperIndices); inserted.forEach((pg) => out.addPage(pg)); } } if (helperIndices.length) { shiftLayerKeysInsertAfter(afterPage1Based); } return { out, focusPage: afterPage1Based + 1 }; } async function addBlankPage() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } return withStudioLoading('Adding page…', async () => { const helper = await PDFDocument.create(); helper.addPage(); const { out, focusPage } = await insertPagesAfter(state.page, helper); const bytes = await out.save(); await applyPdfBytesSmooth(bytes, focusPage, { preserveLayers: true, silent: true, busyMessage: 'Adding page…' }); toastOk('Blank page added'); }, { button: $('psPageAdd'), silent: true }); } async function addImageAsPage(file) { if (!file || !state.pdfBytes) return; return withStudioLoading('Adding image page…', async () => { const buf = await file.arrayBuffer(); const helper = await PDFDocument.create(); let img; if (/png$/i.test(file.name) || file.type === 'image/png') { img = await helper.embedPng(buf); } else if (/jpe?g$/i.test(file.name) || file.type === 'image/jpeg') { img = await helper.embedJpg(buf); } else { toastErr('Use PNG or JPG image'); return; } const scaled = img.scale(1); const page = helper.addPage([612, 792]); const { width: pw, height: ph } = page.getSize(); const scale = Math.min(pw / scaled.width, ph / scaled.height, 1); const dw = scaled.width * scale; const dh = scaled.height * scale; page.drawImage(img, { x: (pw - dw) / 2, y: (ph - dh) / 2, width: dw, height: dh, }); const { out, focusPage } = await insertPagesAfter(state.page, helper); const bytes = await out.save(); await applyPdfBytesSmooth(bytes, focusPage, { preserveLayers: true, silent: true, busyMessage: 'Adding image page…' }); toastOk('Image page added'); }, { silent: true }); } async function copyCurrentPage() { if (!state.pdfBytes || state.pageCount < 1) return; return withStudioLoading('Copying page…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const idx = state.page - 1; const [copied] = await pdf.copyPages(pdf, [idx]); const srcLayers = state.layers[String(state.page)] ? JSON.parse(JSON.stringify(state.layers[String(state.page)])) : null; pdf.insertPage(state.page, copied); shiftLayerKeysInsertAfter(state.page); if (srcLayers) { state.layers[String(state.page + 1)] = srcLayers; } await reloadPdfDocument(pdf, state.page + 1, { busyMessage: 'Copying page…' }); toastOk('Page copied'); }, { button: $('psPageCopy'), silent: true }); } async function deleteCurrentPage() { if (state.pageCount <= 1) { toastErr('Cannot delete the only page'); return; } if (!confirm('Delete page ' + state.page + '?')) return; return withStudioLoading('Deleting page…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const removed = state.page; pdf.removePage(removed - 1); removeLayerPage(removed); await reloadPdfDocument(pdf, Math.min(removed, pdf.getPageCount()), { busyMessage: 'Deleting page…' }); toastOk('Page deleted'); }, { button: $('psPageDelete'), silent: true }); } async function rotateCurrentPage() { if (!state.pdfBytes) return; return withStudioLoading('Rotating page…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const p = pdf.getPages()[state.page - 1]; const cur = p.getRotation().angle; p.setRotation(degrees((cur + 90) % 360)); await reloadPdfDocument(pdf, state.page, { busyMessage: 'Rotating page…' }); toastOk('Page rotated'); }, { button: $('psPageRotate'), silent: true }); } async function moveCurrentPage(direction) { if (!state.pdfBytes || state.pageCount < 2) return; const from = state.page - 1; const to = direction === 'up' ? from - 1 : from + 1; if (to < 0 || to >= state.pageCount) return; await reorderPages(from, to); } async function refreshPageRail() { const list = $('psPagesRailList'); if (!list || !state.pdfJsDoc || !state.pageCount) { if (list) list.innerHTML = ''; return; } const gen = ++pageRailGen; list.innerHTML = ''; $('psPageCountLbl').textContent = '(' + state.pageCount + ')'; $('psPagesRail')?.classList.remove('d-none'); for (let i = 1; i <= state.pageCount; i++) { if (gen !== pageRailGen) return; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ps-page-rail-item' + (i === state.page ? ' active' : ''); btn.dataset.page = String(i); const canvas = document.createElement('canvas'); const label = document.createElement('span'); label.textContent = String(i); const grip = document.createElement('span'); grip.className = 'ps-page-rail-grip'; grip.setAttribute('aria-hidden', 'true'); grip.innerHTML = ''; btn.appendChild(canvas); btn.appendChild(label); btn.appendChild(grip); btn.addEventListener('click', () => { if (pageReorderBusy || pageRailJustDragged) return; gotoPage(i); }); list.appendChild(btn); try { const page = await state.pdfJsDoc.getPage(i); if (gen !== pageRailGen) return; const vp1 = page.getViewport({ scale: 1 }); const thumbScale = Math.min(72 / vp1.width, 96 / vp1.height, 0.35); const vp = page.getViewport({ scale: thumbScale }); canvas.width = Math.max(1, Math.floor(vp.width)); canvas.height = Math.max(1, Math.floor(vp.height)); await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; } catch (err) { console.warn('Thumb', i, err); } } updatePageRailActive(); initPageRailSortable(); } function isMobileStudioLayout() { return typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 991px)').matches; } async function initPageRailSortable() { const list = $('psPagesRailList'); if (!list || state.pageCount < 2) { if (pageSortable) { pageSortable.destroy(); pageSortable = null; } return; } try { const { default: Sortable } = await import('https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/+esm'); if (pageSortable) { pageSortable.destroy(); } const mobile = isMobileStudioLayout(); pageSortable = Sortable.create(list, { animation: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', direction: mobile ? 'horizontal' : 'vertical', delay: mobile ? 80 : 120, delayOnTouchOnly: true, touchStartThreshold: 2, swapThreshold: 0.65, draggable: '.ps-page-rail-item', ghostClass: 'ps-page-rail-ghost', chosenClass: 'ps-page-rail-chosen', dragClass: 'ps-page-rail-drag', forceFallback: true, fallbackOnBody: true, fallbackTolerance: 2, scroll: true, bubbleScroll: true, scrollSensitivity: 60, scrollSpeed: 12, onStart: () => { pageRailJustDragged = false; list.classList.add('is-sorting'); }, onMove: () => { pageRailJustDragged = true; return true; }, onEnd: async (evt) => { list.classList.remove('is-sorting'); setTimeout(() => { pageRailJustDragged = false; }, 80); if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return; pageRailJustDragged = true; await reorderPages(evt.oldIndex, evt.newIndex); }, }); } catch (e) { console.warn('Sortable init failed', e); } } function applyHashMode() { const hm = parseHashMode(); if (hm) showModePanel(hm); else showModePanel(slugMode); } async function loadPdfBytes(bytes, filename, focusPage = 1, opts = {}) { const onUploadScreen = opts.uploadLoading ?? ($('psWorkspace')?.classList.contains('d-none') ?? true); const canvasBusy = opts.canvasBusy !== false; try { if (onUploadScreen) setUploadLoading(true, 'Parsing PDF…'); if (canvasBusy) setCanvasBusy(true, 'Loading document…'); state.pdfBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); await cancelPageRender(); pageRenderGen += 1; if (state.pdfJsDoc?.destroy) { try { state.pdfJsDoc.destroy(); } catch (_) {} } if (onUploadScreen) setUploadLoading(true, 'Rendering pages…'); else if (canvasBusy) setCanvasBusy(true, 'Rendering pages…'); state.pdfJsDoc = await pdfjsLib.getDocument({ data: state.pdfBytes.slice(0) }).promise; state.pageCount = state.pdfJsDoc.numPages; state.page = Math.max(1, Math.min(focusPage, state.pageCount)); state.layers = {}; state.pageDims = {}; state.pdfRuns = {}; state.selectedId = null; state.nextId = 1; zoomUserAdjusted = false; setWorkspaceVisible(true); applyHashMode(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await renderPage(); await refreshPageRail(); updatePageActionButtons(); updatePropPanel(); toastOk(filename ? `Loaded ${filename}` : 'PDF loaded'); } catch (err) { console.error(err); toastErr(err?.message || 'Could not read PDF'); } finally { if (onUploadScreen) setUploadLoading(false); if (canvasBusy) setCanvasBusy(false); } } async function loadPdfFile(file) { if (!file) return; if (!(file.type === 'application/pdf' || /\.pdf$/i.test(file.name))) { toastWarn('Only PDF files open in the editor. Use Merge for multiple PDFs.'); return; } setUploadLoading(true, 'Reading file…'); try { const buf = await file.arrayBuffer(); const sizeMb = (file.size / (1024 * 1024)).toFixed(1); const hint = file.size > 2 * 1024 * 1024 ? ` (${sizeMb} MB — please wait)` : ''; setUploadLoading(true, `Processing ${file.name}${hint}…`); await loadPdfBytes(new Uint8Array(buf), file.name); } catch (err) { console.error(err); toastErr('Could not read file'); setUploadLoading(false); setCanvasBusy(false); } } async function exportPdfDownload(filename) { if (!state.pdfBytes) { toastErr('No PDF loaded'); return; } return withStudioLoading('Exporting PDF…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const font = await pdf.embedFont(StandardFonts.Helvetica); const pages = pdf.getPages(); for (let i = 0; i < pages.length; i++) { const pnum = i + 1; const items = state.layers[String(pnum)] || []; const page = pages[i]; const { width: pw, height: ph } = page.getSize(); const dim = state.pageDims[pnum] || { w: pw, h: ph }; const scaleF = Math.min(pw / dim.w, ph / dim.h); for (const it of items) { if (it.type === 'cover' || it.type === 'highlight') { const cr = clampRectToPage(it.nx * pw, ph - (it.ny + it.nh) * ph, it.nw * pw, it.nh * ph, pw, ph); if (cr) { const c = layerRgb(it); const col = rgb(c.r / 255, c.g / 255, c.b / 255); page.drawRectangle({ ...cr, color: col, opacity: it.type === 'highlight' ? 0.4 : 1, borderWidth: 0, }); } } else if (it.type === 'redact') { const cr = clampRectToPage(it.nx * pw, ph - (it.ny + it.nh) * ph, it.nw * pw, it.nh * ph, pw, ph); if (cr) { const c = layerRgb(it); page.drawRectangle({ ...cr, color: rgb(c.r / 255, c.g / 255, c.b / 255), borderWidth: 0, }); } } else if (it.type === 'nativeText' || it.type === 'text' || it.type === 'sticky') { const fontPt = (it.fontPt || 12) * scaleF; if (it.type === 'nativeText') { const cr = clampRectToPage(it.nx * pw - 4, ph - (it.ny + it.nh) * ph - 4, it.nw * pw + 8, it.nh * ph + 8, pw, ph); if (cr) page.drawRectangle({ ...cr, color: rgb(1, 1, 1), borderWidth: 0 }); } const x = Math.max(0, it.nx * pw); const baseline = ph - it.ny * ph - fontPt * 0.88; drawTextSafe(page, it.text || '', { x, y: Math.max(0, Math.min(baseline, ph - 1)), size: Math.max(4, Math.min(fontPt, 200)), font, color: rgbPdf(it.r ?? 30, it.g ?? 58, it.b ?? 95), }); } else if (it.type === 'pen' && it.points?.length > 1) { for (let pi = 1; pi < it.points.length; pi++) { const p0 = it.points[pi - 1]; const p1 = it.points[pi]; page.drawLine({ start: { x: (p0.x / overlay.width) * pw, y: ph - (p0.y / overlay.height) * ph }, end: { x: (p1.x / overlay.width) * pw, y: ph - (p1.y / overlay.height) * ph }, thickness: 1.5, color: rgbPdf(it.r ?? 30, it.g ?? 58, it.b ?? 95), }); } } else if ((it.type === 'image' || it.type === 'signature') && it.dataUrl) { const imgBytes = await fetch(it.dataUrl).then((r) => r.arrayBuffer()); let embedded; if (/^data:image\/jpe?g/i.test(it.dataUrl)) { embedded = await pdf.embedJpg(imgBytes); } else { embedded = await pdf.embedPng(imgBytes); } const x = it.nx * pw; const y = ph - (it.ny + it.nh) * ph; const w = it.nw * pw; const h = it.nh * ph; page.drawImage(embedded, { x, y, width: w, height: h }); } } } const out = await pdf.save(); downloadBlob(new Blob([out], { type: 'application/pdf' }), filename || 'document.pdf'); toastOk('PDF exported'); }, { export: true, canvas: true }); } function downloadBlob(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); } /* ——— Operations ——— */ function addMergeFiles(fileList) { const incoming = [...(fileList || [])].filter(isPdfFile); if (!incoming.length) { toastWarn('Please choose PDF files only'); return false; } state.mergeFiles.push(...incoming); renderMergeList(); if (isMergeMode()) { $('psMergeUploadPanel')?.classList.remove('d-none'); showModePanel('merge'); syncWorkspaceView('merge'); } return true; } async function runMerge() { if (state.mergeFiles.length < 2) { toastErr('Add at least 2 PDF files to merge'); return; } const btn = $('psMergeRun') || $('psMergeUploadRun'); return withStudioLoading('Merging PDFs…', async () => { const out = await PDFDocument.create(); for (let i = 0; i < state.mergeFiles.length; i++) { const file = state.mergeFiles[i]; if (state.pdfBytes) { setCanvasBusy(true, `Merging file ${i + 1} of ${state.mergeFiles.length}…`); } else if ($('psDropLoadingText')) { $('psDropLoadingText').textContent = `Merging file ${i + 1} of ${state.mergeFiles.length}…`; } const buf = await file.arrayBuffer(); const src = await PDFDocument.load(buf, { ignoreEncryption: true }); const pages = await out.copyPages(src, src.getPageIndices()); pages.forEach((p) => out.addPage(p)); } const bytes = await out.save(); state.mergeFiles = []; renderMergeList(); await loadPdfBytes(bytes, 'merged.pdf', 1, { uploadLoading: false }); $('psPanelEdit')?.classList.remove('ps-merge-setup'); openEditAfterTool('Merged — edit, add images/signatures, or export'); }, { button: btn, upload: !state.pdfBytes, canvas: Boolean(state.pdfBytes) }); } async function runSplit() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } const from = Math.max(1, Number($('psSplitFrom').value) || 1); const to = Math.min(state.pageCount, Number($('psSplitTo').value) || state.pageCount); return withStudioLoading('Splitting PDF…', async () => { const src = await PDFDocument.load(state.pdfBytes); const out = await PDFDocument.create(); const indices = []; for (let i = from - 1; i < to; i++) indices.push(i); const pages = await out.copyPages(src, indices); pages.forEach((p) => out.addPage(p)); await loadPdfBytes(await out.save(), 'split.pdf', 1, { uploadLoading: false }); openEditAfterTool('Pages extracted — continue editing or export'); }, { button: $('psSplitRun') }); } async function runRotate() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } const deg = Number($('psRotateDeg').value) || 90; return withStudioLoading('Rotating pages…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const rot = degrees(deg); pdf.getPages().forEach((p) => p.setRotation(rot)); await reloadPdfDocument(pdf, state.page, { busyMessage: 'Rotating pages…' }); openEditAfterTool('Pages rotated — preview in editor, then export'); }, { button: $('psRotateRun') }); } async function runWatermark() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } const text = ($('psWmText').value || 'CONFIDENTIAL').trim(); const size = Math.min(120, Math.max(8, Number($('psWmSize').value) || 42)); const opacity = Math.min(1, Math.max(0.05, Number($('psWmOpacity').value) || 0.25)); const angle = Number($('psWmAngle').value) || 35; return withStudioLoading('Adding watermark…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const font = await pdf.embedFont(StandardFonts.HelveticaBold); pdf.getPages().forEach((page) => { const { width: w, height: h } = page.getSize(); page.drawText(winAnsiSafe(text), { x: w * 0.15, y: h * 0.45, size, font, color: rgb(0.75, 0.75, 0.75), opacity, rotate: degrees(angle), }); }); await reloadPdfDocument(pdf, state.page, { busyMessage: 'Adding overlay…' }); openEditAfterTool('Overlay applied — edit further or export'); }, { button: $('psWmRun') }); } function pageNumberCoords(page, pos, label, font, size = 10) { const { width: w, height: h } = page.getSize(); const textW = font.widthOfTextAtSize(label, size); const marginX = 48; const bottomY = 28; const topY = h - 36; const coords = { 'top-left': { x: marginX, y: topY }, 'top-center': { x: (w - textW) / 2, y: topY }, 'top-right': { x: w - marginX - textW, y: topY }, 'bottom-left': { x: marginX, y: bottomY }, 'bottom-center': { x: (w - textW) / 2, y: bottomY }, 'bottom-right': { x: w - marginX - textW, y: bottomY }, }; return coords[pos] || coords['bottom-center']; } async function runPageNumbers() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } const pos = $('psNumPos').value || 'bottom-center'; return withStudioLoading('Adding page numbers…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const font = await pdf.embedFont(StandardFonts.Helvetica); const total = pdf.getPageCount(); const size = 10; pdf.getPages().forEach((page, i) => { const label = `${i + 1} / ${total}`; const { x, y } = pageNumberCoords(page, pos, label, font, size); drawTextSafe(page, label, { x, y, size, font, color: rgb(0.35, 0.35, 0.35) }); }); await reloadPdfDocument(pdf, state.page, { busyMessage: 'Adding page numbers…' }); openEditAfterTool('Page numbers added — edit or export'); }, { button: $('psNumRun') }); } async function runCompress() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } toastWarn('Re-saving PDF — for image-heavy files, export as images for smaller size'); return withStudioLoading('Compressing PDF…', async () => { const pdf = await PDFDocument.load(state.pdfBytes); const bytes = await pdf.save({ useObjectStreams: true }); await applyPdfBytesSmooth(bytes, state.page, { preserveLayers: true, silent: true, busyMessage: 'Compressing PDF…' }); openEditAfterTool('Compressed — preview in editor, then export'); }, { button: $('psCompressRun') }); } function buildPageThumbs() { const el = $('psPageThumbs'); if (!el || !state.pageCount) return; if (organizeThumbsSortable) { try { organizeThumbsSortable.destroy(); } catch (_) {} organizeThumbsSortable = null; } el.innerHTML = ''; const order = []; const isRemove = slug === 'remove-pdf-pages'; const canDragReorder = !isRemove && state.pageCount > 1; for (let i = 1; i <= state.pageCount; i++) { order.push(i); const d = document.createElement('button'); d.type = 'button'; d.className = 'ps-page-thumb' + (isRemove ? ' active' : ''); d.textContent = 'P' + i; d.dataset.page = String(i); d.title = isRemove ? 'Click to toggle keep/remove' : (canDragReorder ? 'Hold & slide to reorder · tap to open' : 'Go to page ' + i); d.addEventListener('click', (e) => { if (isRemove) { e.preventDefault(); d.classList.toggle('active'); syncPageOrderFromThumbs(); } else if (!pageRailJustDragged) { gotoPage(i); } }); el.appendChild(d); } $('psPageOrder').value = order.join(','); const hint = $('psOrganizeHint'); if (hint) { hint.textContent = isRemove ? 'Click pages to remove (dimmed = removed). Then apply.' : (isMobileStudioLayout() ? 'Hold a page and slide to reorder, or use the page strip above the bottom menu. Then Apply.' : 'Drag pages in the left rail to reorder, or edit order below (e.g. 3,1,2).'); } if (canDragReorder) { initOrganizeThumbsSortable(el); } } async function initOrganizeThumbsSortable(el) { if (!el) return; try { const { default: Sortable } = await import('https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/+esm'); if (organizeThumbsSortable) { organizeThumbsSortable.destroy(); organizeThumbsSortable = null; } const mobile = isMobileStudioLayout(); organizeThumbsSortable = Sortable.create(el, { animation: 160, direction: 'horizontal', delay: mobile ? 70 : 100, delayOnTouchOnly: true, touchStartThreshold: 2, draggable: '.ps-page-thumb', ghostClass: 'ps-page-rail-ghost', chosenClass: 'ps-page-rail-chosen', forceFallback: true, fallbackOnBody: true, scroll: true, bubbleScroll: true, onStart: () => { pageRailJustDragged = false; }, onMove: () => { pageRailJustDragged = true; return true; }, onEnd: (evt) => { setTimeout(() => { pageRailJustDragged = false; }, 80); if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return; const pages = [...el.querySelectorAll('.ps-page-thumb')] .map((n) => n.dataset.page) .filter(Boolean); if ($('psPageOrder')) $('psPageOrder').value = pages.join(','); }, }); } catch (e) { console.warn('Organize thumbs Sortable failed', e); } } function syncPageOrderFromThumbs() { const kept = [...document.querySelectorAll('.ps-page-thumb.active')] .map((el) => el.dataset.page) .filter(Boolean); if ($('psPageOrder')) $('psPageOrder').value = kept.join(','); } async function runProtect() { const pass = ($('psProtectPass')?.value || '').trim(); if (!pass) { toastErr('Enter a password'); return; } if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } return withStudioLoading('Protecting PDF…', async () => { const fd = new FormData(); fd.append('pdf', new Blob([state.pdfBytes], { type: 'application/pdf' }), 'document.pdf'); fd.append('password', pass); const res = await fetch(protectApiUrl, { method: 'POST', body: fd }); if (!res.ok) { toastErr((await res.text()) || 'Protect failed — install qpdf (QPDF_BIN)'); return; } await loadPdfBytes(new Uint8Array(await res.arrayBuffer()), 'protected.pdf', 1, { uploadLoading: false }); openEditAfterTool('PDF protected — preview in editor or export'); }, { button: $('psProtectRun') }); } function initSignCanvas() { const c = $('psSignCanvas'); if (!c || c.dataset.bound) return; c.dataset.bound = '1'; const ctx = c.getContext('2d'); ctx.strokeStyle = '#0f172a'; ctx.lineWidth = 2; ctx.lineCap = 'round'; let drawing = false; const pos = (e) => { const r = c.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { x: (t.clientX - r.left) * (c.width / r.width), y: (t.clientY - r.top) * (c.height / r.height), }; }; const down = (e) => { e.preventDefault(); drawing = true; const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); }; const move = (e) => { if (!drawing) return; e.preventDefault(); const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); }; const up = () => { drawing = false; if (c.dataset) c.dataset.hasInk = '1'; }; c.addEventListener('mousedown', down); c.addEventListener('mousemove', move); window.addEventListener('mouseup', up); c.addEventListener('touchstart', down, { passive: false }); c.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up); $('psSignClear')?.addEventListener('click', () => { ctx.clearRect(0, 0, c.width, c.height); delete c.dataset.hasInk; }); } async function runSign() { const c = $('psSignCanvas'); if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } if (!c) return; const pageNum = Math.max(1, Math.min(state.pageCount, Number($('psSignPage')?.value) || state.page)); if (pageNum !== state.page) gotoPage(pageNum); if (!c.dataset.hasInk) { toastErr('Draw your signature first'); return; } const dataUrl = c.toDataURL('image/png'); return withStudioLoading('Placing signature…', async () => { if (!overlay.width && state.pdfJsDoc) await renderPage(); const scale = (Number($('psSignScale')?.value) || 180) / Math.max(c.width, 1); const it = await addImageLayerFromDataUrl(dataUrl, 'signature'); it.nw = Math.min(0.55, scale * 0.9); it.nh = it.nw * (c.height / Math.max(c.width, 1)) * (overlay.width / Math.max(overlay.height, 1)); redrawOverlay(); openEditAfterTool('Signature placed — drag to move, corners to resize'); }, { button: $('psSignApply') }); } async function runOrganize() { if (!state.pdfBytes) { toastErr('Load a PDF first'); return; } const raw = ($('psPageOrder').value || '').trim(); const parts = raw.split(/[,;\s]+/).map((x) => parseInt(x, 10)).filter((n) => n >= 1 && n <= state.pageCount); if (!parts.length) { toastErr('Enter valid page order'); return; } return withStudioLoading('Reordering pages…', async () => { const src = await PDFDocument.load(state.pdfBytes); const out = await PDFDocument.create(); const pages = await out.copyPages(src, parts.map((n) => n - 1)); pages.forEach((p) => out.addPage(p)); await loadPdfBytes(await out.save(), 'organized.pdf', 1, { uploadLoading: false }); openEditAfterTool('Pages updated — preview in editor, then export'); }, { button: $('psOrganizeRun') }); } async function exportImagesZip(fmt) { if (!state.pdfJsDoc) { toastErr('Load a PDF first'); return; } const label = fmt === 'jpeg' ? 'Exporting JPG pages…' : 'Exporting PNG pages…'; return withStudioLoading(label, async () => { const { default: JSZip } = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm'); const zip = new JSZip(); const mime = fmt === 'jpeg' ? 'image/jpeg' : 'image/png'; const ext = fmt === 'jpeg' ? 'jpg' : 'png'; for (let i = 1; i <= state.pageCount; i++) { setCanvasBusy(true, `Rendering page ${i} of ${state.pageCount}…`); const page = await state.pdfJsDoc.getPage(i); const vp = page.getViewport({ scale: 2 }); const c = document.createElement('canvas'); c.width = vp.width; c.height = vp.height; await page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise; const data = c.toDataURL(mime, 0.92).split(',')[1]; zip.file(`page-${String(i).padStart(3, '0')}.${ext}`, data, { base64: true }); } setCanvasBusy(true, 'Creating ZIP…'); const blob = await zip.generateAsync({ type: 'blob' }); downloadBlob(blob, `pdf-pages.${ext}.zip`); toastOk('ZIP downloaded'); }, { export: true, canvas: true, button: $('psImgExportRun') }); } async function runCompare() { const b = $('psCompareB')?.files?.[0]; if (!state.pdfBytes || !b) { toastErr('Load main PDF and select a second file'); return; } return withStudioLoading('Comparing PDFs…', async () => { const [a, bdoc] = await Promise.all([ pdfjsLib.getDocument({ data: state.pdfBytes.slice(0) }).promise, pdfjsLib.getDocument({ data: new Uint8Array(await b.arrayBuffer()) }).promise, ]); const out = $('psCompareOut'); const samePages = a.numPages === bdoc.numPages; let aText = ''; let bText = ''; for (let i = 1; i <= Math.min(a.numPages, 3); i++) { const pa = await (await a.getPage(i)).getTextContent(); aText += pa.items.map((it) => it.str).join(' '); } for (let i = 1; i <= Math.min(bdoc.numPages, 3); i++) { const pb = await (await bdoc.getPage(i)).getTextContent(); bText += pb.items.map((it) => it.str).join(' '); } const match = aText.slice(0, 500) === bText.slice(0, 500); out.textContent = `Pages: ${a.numPages} vs ${bdoc.numPages} (${samePages ? 'same count' : 'different'})\nFirst pages text: ${match ? 'MATCH' : 'DIFFERENT'}`; toastOk('Compared'); }, { button: $('psCompareRun'), canvas: true }); } async function exportText() { if (!state.pdfJsDoc) { toastErr('Load a PDF first'); return; } return withStudioLoading('Extracting text…', async () => { let text = ''; for (let i = 1; i <= state.pageCount; i++) { setCanvasBusy(true, `Reading page ${i} of ${state.pageCount}…`); const page = await state.pdfJsDoc.getPage(i); const tc = await page.getTextContent(); text += `\n--- Page ${i} ---\n` + tc.items.map((it) => it.str).join(' '); } downloadBlob(new Blob([text.trim()], { type: 'text/plain' }), 'extracted.txt'); toastOk('Text downloaded'); }, { export: true, canvas: true }); } function buildMergeListRow(file, idx) { const row = document.createElement('div'); row.className = 'ps-file-item'; row.draggable = true; row.dataset.idx = String(idx); row.innerHTML = `${file.name}`; return row; } function fillMergeListContainer(el) { if (!el) return; el.innerHTML = ''; state.mergeFiles.forEach((file, idx) => { el.appendChild(buildMergeListRow(file, idx)); }); } async function initMergeListSortable() { const list = $('psMergeUploadList') || $('psMergeList'); if (!list || state.mergeFiles.length < 2) { if (mergeListSortable) { mergeListSortable.destroy(); mergeListSortable = null; } return; } try { const { default: Sortable } = await import('https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/+esm'); if (mergeListSortable) mergeListSortable.destroy(); mergeListSortable = Sortable.create(list, { animation: 150, handle: '.ps-file-item', draggable: '.ps-file-item', onEnd: (evt) => { if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return; const [moved] = state.mergeFiles.splice(evt.oldIndex, 1); state.mergeFiles.splice(evt.newIndex, 0, moved); renderMergeList(); }, }); } catch (e) { console.warn('Merge list sortable failed', e); } } function removeMergeFileAt(idx) { if (idx < 0 || idx >= state.mergeFiles.length) return; state.mergeFiles.splice(idx, 1); renderMergeList(); if (!state.mergeFiles.length) { $('psPanelEdit')?.classList.remove('ps-merge-setup'); setWorkspaceVisible(false); $('psUploadScreen')?.classList.remove('d-none'); } showModePanel('merge'); } function renderMergeList() { fillMergeListContainer($('psMergeList')); fillMergeListContainer($('psMergeUploadList')); $('psMergeUploadPanel')?.classList.toggle('d-none', state.mergeFiles.length === 0); const count = state.mergeFiles.length; const runBtn = $('psMergeRun'); const uploadRun = $('psMergeUploadRun'); if (runBtn) runBtn.disabled = count < 2; if (uploadRun) uploadRun.disabled = count < 2; initMergeListSortable(); if (isMergeMode() && !state.pdfBytes) syncWorkspaceView('merge'); } /* ——— Event wiring ——— */ function bindEditorEvents() { document.querySelectorAll('.ps-tool-btn').forEach((b) => { b.addEventListener('click', () => setTool(b.dataset.tool)); }); $('psZoomIn')?.addEventListener('click', () => { zoomUserAdjusted = true; state.scale = clampZoom(state.scale + 0.15); renderPage(); }); $('psZoomOut')?.addEventListener('click', () => { zoomUserAdjusted = true; state.scale = clampZoom(state.scale - 0.15); renderPage(); }); $('psTogglePages')?.addEventListener('click', () => { toggleMobilePagesRail(); }); $('psPageAdd')?.addEventListener('click', addBlankPage); $('psPageAddImage')?.addEventListener('click', () => $('psPageImageInput')?.click()); $('psPageImageInput')?.addEventListener('change', (e) => { const f = e.target.files?.[0]; if (f) addImageAsPage(f); e.target.value = ''; }); $('psPageCopy')?.addEventListener('click', copyCurrentPage); $('psPageRotate')?.addEventListener('click', rotateCurrentPage); $('psPageUp')?.addEventListener('click', () => moveCurrentPage('up')); $('psPageDown')?.addEventListener('click', () => moveCurrentPage('down')); $('psPageDelete')?.addEventListener('click', deleteCurrentPage); $('psDelSel')?.addEventListener('click', () => { clearLoadedPdfAndPickNew(); }); $('psApplyProps')?.addEventListener('click', () => { const sel = getItems(state.page).find((x) => x.id === state.selectedId); if (!sel) return; sel.text = $('psPropText').value || ' '; sel.fontPt = Math.min(72, Math.max(6, Number($('psPropSize').value) || 12)); const rgbC = hexToRgb($('psPropColor').value); sel.r = rgbC.r; sel.g = rgbC.g; sel.b = rgbC.b; if (['text', 'nativeText', 'sticky'].includes(sel.type)) { const cw = overlay.width; const ch = overlay.height; const m = textMetrics(sel, cw, ch); sel.nw = m.w / cw; sel.nh = m.h / ch; } redrawOverlay(); toastOk('Updated'); }); overlay.addEventListener('mousedown', onOverlayDown); overlay.addEventListener('mousemove', onOverlayMove); overlay.addEventListener('mouseup', onOverlayUp); overlay.addEventListener('mouseleave', () => { // Keep active drag alive — document pointer handlers finish it if (state.drag) return; if (state.rectDraw || state.penPath) return; state.rectDraw = null; state.penPath = null; redrawOverlay(); }); overlay.addEventListener('touchstart', onOverlayTouchStart, { passive: false }); overlay.addEventListener('touchmove', onOverlayTouchMove, { passive: false }); overlay.addEventListener('touchend', onOverlayTouchEnd, { passive: false }); overlay.addEventListener('touchcancel', onOverlayTouchEnd, { passive: false }); ensureDocPointerListeners(); $('psEditorImageInput')?.addEventListener('change', async (e) => { const f = e.target.files?.[0]; e.target.value = ''; if (!f || !state.pdfBytes) return; try { const dataUrl = await readFileAsDataUrl(f); await addImageLayerFromDataUrl(dataUrl, 'image'); toastOk('Image added — drag to move, corner handles to resize'); } catch (err) { console.error(err); toastErr('Could not load image'); } }); overlay.addEventListener('dblclick', (ev) => { if (state.tool !== 'select') return; const { x, y } = canvasPoint(ev); const hit = hitTest(x, y); if (hit && ['text', 'nativeText', 'sticky'].includes(hit.type)) { ev.preventDefault(); startInlineTextEdit(hit, { clearPlaceholder: false, selectAll: true }); } }); } function overlayPointer(ev) { return { clientX: ev.clientX, clientY: ev.clientY }; } function overlayHitAtClient(clientX, clientY) { const { x, y } = canvasPoint({ clientX, clientY }); let hit = hitTest(x, y); if (state.selectedId && !hit) { const sel = getItems(state.page).find((o) => o.id === state.selectedId); if (sel && hitResizeHandle(x, y, sel)) hit = sel; } return { x, y, hit }; } function onOverlayTouchStart(ev) { if (!ev.touches?.length) return; if (ev.touches.length >= 2 || pinchActive) { state.drag = null; state.rectDraw = null; state.penPath = null; overlayTouchIdle = null; overlayTouchPending = null; setTouchCapture(false); return; } const t = ev.touches[0]; // Drawing / place tools always capture the finger if (state.tool !== 'select') { ev.preventDefault(); setTouchCapture(true); onOverlayDown(overlayPointer(t)); return; } const { hit } = overlayHitAtClient(t.clientX, t.clientY); if (hit) { // Defer drag — if user slides to scroll/pan, don't steal the gesture overlayTouchPending = { id: hit.id, cx: t.clientX, cy: t.clientY }; overlayTouchIdle = null; setTouchCapture(false); return; } // Empty space: let the preview pan/scroll; remember tap to clear selection overlayTouchIdle = { cx: t.clientX, cy: t.clientY, moved: false }; overlayTouchPending = null; setTouchCapture(false); } function onOverlayTouchMove(ev) { if (!ev.touches?.length) return; if (ev.touches.length >= 2 || pinchActive) { overlayTouchPending = null; return; } if (state.drag || state.rectDraw || state.penPath) { ev.preventDefault(); onOverlayMove(overlayPointer(ev.touches[0])); return; } const t = ev.touches[0]; if (overlayTouchPending) { const dx = t.clientX - overlayTouchPending.cx; const dy = t.clientY - overlayTouchPending.cy; const dist = Math.hypot(dx, dy); if (dist < 12) return; const col = getCanvasScrollEl(); const canScrollY = col && col.scrollHeight > col.clientHeight + 2; const canScrollX = col && col.scrollWidth > col.clientWidth + 2; const mostlyY = Math.abs(dy) >= Math.abs(dx); const mostlyX = Math.abs(dx) > Math.abs(dy); // Prefer native scroll/pan when content overflows in that direction if ((mostlyY && canScrollY) || (mostlyX && canScrollX)) { overlayTouchPending = null; return; } // Otherwise start object drag from original point ev.preventDefault(); setTouchCapture(true); const start = { clientX: overlayTouchPending.cx, clientY: overlayTouchPending.cy }; overlayTouchPending = null; onOverlayDown(overlayPointer(start)); onOverlayMove(overlayPointer(t)); return; } if (overlayTouchIdle) { const dx = t.clientX - overlayTouchIdle.cx; const dy = t.clientY - overlayTouchIdle.cy; if ((dx * dx) + (dy * dy) > 64) overlayTouchIdle.moved = true; } // Do not preventDefault — browser scrolls/pans the canvas column } function onOverlayTouchEnd(ev) { if (pinchActive || (ev.touches && ev.touches.length >= 1)) return; if (state.drag || state.rectDraw || state.penPath) { ev.preventDefault(); onOverlayUp(); setTouchCapture(false); overlayTouchIdle = null; overlayTouchPending = null; return; } if (overlayTouchPending) { // Tap on object — select (no drag) const t = ev.changedTouches?.[0]; if (t) onOverlayDown(overlayPointer(t)); state.drag = null; setChromeDragClass(false); updatePropPanel(); redrawOverlay(); overlayTouchPending = null; setTouchCapture(false); return; } if (overlayTouchIdle && !overlayTouchIdle.moved) { if (inlineEditingId) stopInlineTextEdit(true); if (state.selectedId) { state.selectedId = null; updatePropPanel(); redrawOverlay(); } } overlayTouchIdle = null; overlayTouchPending = null; setTouchCapture(false); } function onOverlayDown(ev) { if (!state.pdfJsDoc) return; ensureDocPointerListeners(); const { x, y } = canvasPoint(ev); if (state.tool === 'select') { let hit = hitTest(x, y); if (state.selectedId && !hit) { const sel = getItems(state.page).find((o) => o.id === state.selectedId); if (sel && hitResizeHandle(x, y, sel)) hit = sel; } if (hit?.id === state.selectedId) { const handle = hitResizeHandle(x, y, hit); if (handle) { const cw = overlay.width; const ch = overlay.height; ensureItemBox(hit, cw, ch); const orig = { nx: hit.nx, ny: hit.ny, nw: hit.nw, nh: hit.nh, fontPt: hit.fontPt || 12 }; if (hit.type === 'pen') { orig.bounds = penBounds(hit.points); orig.points = hit.points.map((p) => ({ x: p.x, y: p.y })); } setChromeDragClass(true, true); state.drag = { mode: 'resize', handle, id: hit.id, startX: x, startY: y, orig, }; return; } } state.selectedId = hit ? hit.id : null; if (hit) { if (inlineEditingId && inlineEditingId !== hit.id) stopInlineTextEdit(true); ensureItemBox(hit, overlay.width, overlay.height); const r = itemScreenRect(hit, overlay.width, overlay.height); setChromeDragClass(true, false); const drag = { mode: 'move', id: hit.id, ox: x - r.x, oy: y - r.y }; if (hit.type === 'pen' && hit.points?.length) { drag.penOrigPoints = hit.points.map((p) => ({ x: p.x, y: p.y })); } // Don't start drag if currently typing this item if (inlineEditingId === hit.id) { state.drag = null; setChromeDragClass(false); } else { state.drag = drag; } } else { if (inlineEditingId) stopInlineTextEdit(true); setChromeDragClass(false); state.drag = null; } updatePropPanel(); redrawOverlay(); return; } if (state.tool === 'pdftext') { const run = hitPdfRun(x, y); if (!run) { toastWarn('Click on existing PDF text'); return; } const cw = overlay.width, ch = overlay.height; const it = { id: genId(), type: 'nativeText', nx: run.left / cw, ny: run.top / ch, nw: run.w / cw, nh: run.h / ch, text: run.str, originalStr: run.str, fontPt: run.fontPt, r: 20, g: 24, b: 31 }; getItems(state.page).push(it); state.selectedId = it.id; setTool('select'); updatePropPanel(); redrawOverlay(); startInlineTextEdit(it, { clearPlaceholder: false, selectAll: true }); return; } if (state.tool === 'text') { const cw = overlay.width; const ch = overlay.height; const dim = state.pageDims[state.page] || { w: 612, h: 792 }; const fontPt = 12; const fontPx = fontPt * (cw / dim.w); const w = Math.max(48, fontPx * 0.55 * 6); const h = fontPx * 1.35; const it = { id: genId(), type: 'text', nx: x / cw, ny: y / ch, nw: w / cw, nh: h / ch, text: '', fontPt, r: 30, g: 58, b: 95, }; getItems(state.page).push(it); state.selectedId = it.id; setTool('select'); updatePropPanel(); redrawOverlay(); startInlineTextEdit(it, { clearPlaceholder: true, selectAll: true }); return; } if (state.tool === 'sticky') { const it = { id: genId(), type: 'sticky', nx: x / overlay.width, ny: y / overlay.height, nw: 0.22, nh: 0.1, text: '', fontPt: 12, r: 66, g: 32, b: 6 }; getItems(state.page).push(it); state.selectedId = it.id; setTool('select'); updatePropPanel(); redrawOverlay(); startInlineTextEdit(it, { clearPlaceholder: true, selectAll: true }); return; } if (state.tool === 'pen') { state.penPath = [{ x, y }]; return; } if (['cover', 'redact', 'highlight'].includes(state.tool)) { state.rectDraw = { x0: x, y0: y, x, y, w: 0, h: 0 }; } } function onOverlayMove(ev) { const { x, y } = canvasPoint(ev); if (state.drag?.mode === 'resize' && state.tool === 'select') { const it = getItems(state.page).find((o) => o.id === state.drag.id); if (!it) return; const cw = overlay.width; const ch = overlay.height; const { handle, orig } = state.drag; if (it.type === 'pen' && orig.bounds && orig.points) { const dx = x - state.drag.startX; const dy = y - state.drag.startY; const nb = boundsFromResize(orig.bounds, handle, dx, dy); it.points = scalePenPoints(orig.points, orig.bounds, nb); syncSelectionChrome(); scheduleOverlayRedraw(); return; } const dx = (x - state.drag.startX) / cw; const dy = (y - state.drag.startY) / ch; // Text: scale font + border together so sizes always match if (it.type === 'text' || it.type === 'nativeText') { resizeTextLayerMatched(it, orig, handle, dx, dy, cw, ch); if (inlineEditingId === it.id) positionInlineEditor(it); syncSelectionChrome(); scheduleOverlayRedraw(); return; } const min = 0.025; let nx = orig.nx; let ny = orig.ny; let nw = orig.nw; let nh = orig.nh; if (handle === 'se') { nw = orig.nw + dx; nh = orig.nh + dy; } else if (handle === 'sw') { nw = orig.nw - dx; nh = orig.nh + dy; nx = orig.nx + (orig.nw - nw); } else if (handle === 'ne') { nw = orig.nw + dx; nh = orig.nh - dy; ny = orig.ny + (orig.nh - nh); } else if (handle === 'nw') { nw = orig.nw - dx; nh = orig.nh - dy; nx = orig.nx + (orig.nw - nw); ny = orig.ny + (orig.nh - nh); } else if (handle === 'n') { nh = orig.nh - dy; ny = orig.ny + (orig.nh - nh); } else if (handle === 's') { nh = orig.nh + dy; } else if (handle === 'e') { nw = orig.nw + dx; } else if (handle === 'w') { nw = orig.nw - dx; nx = orig.nx + (orig.nw - nw); } nw = Math.max(min, nw); nh = Math.max(min, nh); // Re-anchor after min clamp for left/top handles if (handle === 'w' || handle === 'nw' || handle === 'sw') { nx = orig.nx + orig.nw - nw; } if (handle === 'n' || handle === 'nw' || handle === 'ne') { ny = orig.ny + orig.nh - nh; } // Keep on page if (nx < 0) { if (handle === 'e' || handle === 'ne' || handle === 'se') nw = Math.max(min, nw + nx); nx = 0; } if (ny < 0) { if (handle === 's' || handle === 'se' || handle === 'sw') nh = Math.max(min, nh + ny); ny = 0; } if (nx + nw > 1) nw = Math.max(min, 1 - nx); if (ny + nh > 1) nh = Math.max(min, 1 - ny); it.nx = nx; it.ny = ny; it.nw = nw; it.nh = nh; if (it.type === 'sticky') { syncTextFontFromBox(it, cw, ch, orig, handle); if (inlineEditingId === it.id) positionInlineEditor(it); } syncSelectionChrome(); scheduleOverlayRedraw(); return; } if (state.drag?.mode === 'move' && state.tool === 'select') { const it = getItems(state.page).find((o) => o.id === state.drag.id); if (!it) return; const cw = overlay.width; const ch = overlay.height; const nx = (x - state.drag.ox) / cw; const ny = (y - state.drag.oy) / ch; if (it.type === 'pen' && it.points?.length && state.drag.penOrigPoints) { const b0 = penBounds(state.drag.penOrigPoints); const dx = x - state.drag.ox - b0.x; const dy = y - state.drag.oy - b0.y; it.points = state.drag.penOrigPoints.map((p) => ({ x: p.x + dx, y: p.y + dy })); } else if (it.type === 'text' && (it.nw == null || it.nh == null)) { const m = textMetrics(it, cw, ch); it.nx = Math.max(0, Math.min(1 - m.w / cw, nx)); it.ny = Math.max(0, Math.min(1 - m.h / ch, ny)); } else if (it.nw != null && it.nh != null) { it.nx = Math.max(0, Math.min(1 - it.nw, nx)); it.ny = Math.max(0, Math.min(1 - it.nh, ny)); } else { it.nx = Math.max(0, Math.min(1, nx)); it.ny = Math.max(0, Math.min(1, ny)); } syncSelectionChrome(); scheduleOverlayRedraw(); return; } if (state.penPath) { state.penPath.push({ x, y }); redrawOverlay(); return; } if (state.rectDraw) { const r = state.rectDraw; r.x = Math.min(r.x0, x); r.y = Math.min(r.y0, y); r.w = Math.abs(x - r.x0); r.h = Math.abs(y - r.y0); redrawOverlay(); } } function onOverlayUp() { if (state.penPath && state.tool === 'pen') { if (state.penPath.length > 3) { const it = { id: genId(), type: 'pen', points: [...state.penPath], r: 30, g: 41, b: 59, width: 2 }; getItems(state.page).push(it); state.selectedId = it.id; toastOk('Drawing added — drag to move, corners to resize'); } state.penPath = null; setTool('select'); updatePropPanel(); redrawOverlay(); setTouchCapture(false); return; } if (state.rectDraw && ['cover', 'redact', 'highlight'].includes(state.tool)) { const r = state.rectDraw; if (r.w > 4 && r.h > 4) { const type = state.tool === 'cover' ? 'cover' : state.tool === 'highlight' ? 'highlight' : 'redact'; const c = defaultRgbForType(type); const it = { id: genId(), type, nx: r.x / overlay.width, ny: r.y / overlay.height, nw: r.w / overlay.width, nh: r.h / overlay.height, r: c.r, g: c.g, b: c.b, }; getItems(state.page).push(it); state.selectedId = it.id; toastOk('Added'); } state.rectDraw = null; setTool('select'); updatePropPanel(); redrawOverlay(); } $('psSelChrome')?.classList.remove('is-resizing', 'is-dragging'); setChromeDragClass(false); state.drag = null; setTouchCapture(false); flushOverlayRedraw(); } function bindUpload() { const input = $('psFileInput'); const drop = $('psDropZone'); $('psSelectBtn')?.addEventListener('click', (e) => { e.stopPropagation(); input?.click(); }); drop?.addEventListener('click', () => input?.click()); input?.addEventListener('change', (e) => { const files = [...(e.target.files || [])]; if (!files.length) return; if (isMergeMode()) { addMergeFiles(files); } else { loadPdfFile(files[0]); } }); drop?.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('dragover'); }); drop?.addEventListener('dragleave', () => drop.classList.remove('dragover')); drop?.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('dragover'); const files = [...(e.dataTransfer.files || [])]; if (isMergeMode()) { addMergeFiles(files); } else if (files[0]) loadPdfFile(files[0]); }); } function bindActions() { $('psMergeAdd')?.addEventListener('click', () => $('psMergeInput')?.click()); $('psMergeUploadAdd')?.addEventListener('click', () => $('psMergeInput')?.click()); $('psMergeInput')?.addEventListener('change', (e) => { addMergeFiles(e.target.files); e.target.value = ''; }); const onMergeListClick = (e) => { const btn = e.target.closest('[data-rm]'); if (btn) removeMergeFileAt(Number(btn.dataset.rm)); }; $('psMergeList')?.addEventListener('click', onMergeListClick); $('psMergeUploadList')?.addEventListener('click', onMergeListClick); $('psMergeRun')?.addEventListener('click', runMerge); $('psMergeUploadRun')?.addEventListener('click', runMerge); $('psSplitRun')?.addEventListener('click', runSplit); $('psRotateRun')?.addEventListener('click', runRotate); $('psWmRun')?.addEventListener('click', runWatermark); $('psNumRun')?.addEventListener('click', runPageNumbers); $('psCompressRun')?.addEventListener('click', runCompress); $('psOrganizeRun')?.addEventListener('click', runOrganize); $('psImgExportRun')?.addEventListener('click', () => exportImagesZip($('psImgFormat')?.value || 'png')); $('psProtectRun')?.addEventListener('click', runProtect); $('psSignApply')?.addEventListener('click', runSign); $('psCompareRun')?.addEventListener('click', runCompare); document.querySelectorAll('[data-export]').forEach((btn) => { btn.addEventListener('click', () => { const t = btn.dataset.export; if (t === 'pdf') exportPdfDownload(root.dataset.title?.replace(/\s+/g, '-').toLowerCase() + '.pdf' || 'document.pdf'); else if (t === 'png') exportImagesZip('png'); else if (t === 'jpg') exportImagesZip('jpeg'); else if (t === 'text') exportText(); }); }); document.querySelectorAll('.ps-nav-link').forEach((a) => { a.addEventListener('click', (e) => { const mode = a.dataset.mode; if (mode && a.href.includes(location.pathname)) { /* same slug — hash handles mode; allow default navigation for different slugs */ } }); }); } window.addEventListener('hashchange', () => { const hm = parseHashMode(); if (!hm) return; showModePanel(hm); }); document.addEventListener('keydown', (e) => { if (e.key === 'Delete' && state.selectedId && !$('psWorkspace')?.classList.contains('d-none')) { deleteSelectedLayer(); toastOk('Removed'); } }); bindEditorEvents(); initSelectionChrome(); bindCanvasGestures(); bindUpload(); bindActions(); initExpandToggle(); initExportDropdown(); initSidebarToggle(); initSignCanvas(); applyHashMode(); syncWorkspaceView(activeMode || slugMode); syncMobileStickyOffsets(); window.addEventListener('load', syncMobileStickyOffsets); window.addEventListener('orientationchange', () => setTimeout(syncMobileStickyOffsets, 200)); let resizeTimer; let lastMobileLayout = isMobileStudioLayout(); window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const mobileNow = isMobileStudioLayout(); if (mobileNow !== lastMobileLayout) { lastMobileLayout = mobileNow; if (state.pdfJsDoc && state.pageCount > 1) { initPageRailSortable(); const thumbs = $('psPageThumbs'); if (thumbs && thumbs.children.length && slug !== 'remove-pdf-pages') { initOrganizeThumbsSortable(thumbs); } } } syncMobileStickyOffsets(); if (!state.pdfJsDoc) return; zoomUserAdjusted = false; renderPage(); }, 280); });