/**
* 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);
});