const { invoke } = window.__TAURI__.core; const $ = (id) => document.getElementById(id); const PT_PER_PX = 96 / 72; // 1pt = 1.333px ở zoom 100% const DPR = window.devicePixelRatio || 1; const state = { path: null, pages: [], // [{index,widthPt,heightPt}] zoom: 1.0, // 1.0 = 100% slots: [], // phần tử DOM theo trang visible: new Set(), // trang đang trong vùng nhìn current: 0, // trang hiện tại (trên cùng vùng nhìn) hits: [], // kết quả tìm kiếm hitIdx: -1, textLayers: {}, // cache hộp ký tự theo trang tool: null, // công cụ chú thích đang chọn color: [255, 230, 0], // màu chú thích (RGB) annotSpecs: [], // chú thích chưa lưu selectedId: null, // annotation đang chọn recentColors: [], // màu dùng gần đây undoStack: [], // snapshot {annotSpecs,pagePlan} trước mỗi hành động (Ctrl+Z) redoStack: [], pagePlan: [], // [{kind:'existing'|'blank', source, srcIndex, widthPt, heightPt, rotationDelta, crop}] organizeMode: false, orgSelected: new Set(), // index trong pagePlan đang chọn (chế độ Tổ chức trang) orgAnchor: null, // index neo cho Shift-click chọn dải orgThumbs: new Map(), // cache dataURL thumbnail theo "source#srcIndex" (tránh render lại khi chọn/xoay) // ----- Phase 4: Sửa nội dung ----- editMode: false, editPage: 0, // trang đang sửa editBase: null, // file làm việc hiện tại (mỗi op materialize ra file tạm mới) editUndo: [], // stack đường dẫn editBase trước mỗi op editRedo: [], editObjects: [], // ObjectInfo của editPage (đọc lại từ editBase sau mỗi op) editSel: null, // index object đại diện đang chọn editSelRuns: [], // mọi run thuộc khung đang chọn (dòng text = nhiều run) editScale: 1, // px/pt của ảnh stage editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường) editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn) secMode: false, // thanh Bảo mật (Phase 5) đang mở redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng formMode: false, // thanh Form (Phase 6) đang mở formFields: [], // field đọc từ tài liệu hiện tại convMode: false, // thanh Chuyển đổi (Phase 7) đang mở }; const UNDO_LIMIT = 50; let annotIdSeq = 1; const PRESET_COLORS = [ [255, 230, 0], [255, 145, 0], [255, 0, 0], [233, 30, 99], [156, 39, 176], [63, 81, 181], [33, 150, 243], [0, 188, 212], [0, 150, 136], [76, 175, 80], [0, 0, 0], [120, 120, 120], ]; function displaySize(p) { return { w: Math.round(p.widthPt * PT_PER_PX * state.zoom), h: Math.round(p.heightPt * PT_PER_PX * state.zoom), }; } // ---------- Khởi tạo ---------- function shortName(path) { return path.split(/[\\/]/).pop(); } async function loadDocument(path) { try { state.path = path; state.textLayers = {}; state.hits = []; state.hitIdx = -1; state.current = 0; state.annotSpecs = []; state.undoStack = []; state.redoStack = []; state.redactMarks = []; updateRedactButtons(); state.formFields = []; updateUndoRedoButtons(); setTool(null); updateAnnotCount(); buildComments(); $("searchBox").value = ""; $("searchCount").textContent = "—"; let meta; try { meta = await invoke("open_document", { path }); } catch (e) { // File hỏng (xref/trailer sai) — tự sửa qua QPDF rồi mở lại (Phase 3). const usable = await invoke("ensure_openable", { path, password: null }); if (usable === path) throw e; state.path = usable; meta = await invoke("open_document", { path: usable }); } state.pages = meta.pages; buildPages(); buildThumbnails(); buildOutline(meta.outline); $("status").textContent = `${meta.pageCount} trang · ${shortName(path)}`; updatePageTotal(); updateCurrentPage(); updateZoomLabel(); // Khởi tạo plan mặc định ngay (tất cả trang theo thứ tự gốc). // Load plan thực tế ở background để không block UI khi mở file. state.pagePlan = state.pages.map((p) => ({ page: p.index, rotate: 0 })); invoke("organize_identity_plan", { path: state.path, password: null }) .then((plan) => { state.pagePlan = plan; }) .catch(() => {}); state.orgSelected = new Set(); state.orgAnchor = null; state.orgThumbs = new Map(); if (state.organizeMode) buildOrganizeGrid(); } catch (e) { $("status").textContent = "Lỗi mở tài liệu: " + e; } } async function boot() { // Nếu app được mở qua "Open with FoFreeXit" / double-click file PDF trong // Explorer, Windows truyền đường dẫn file qua command-line — ưu tiên mở nó. const fromExplorer = await invoke("initial_file"); loadDocument(fromExplorer || (await invoke("default_pdf"))); } async function openFile() { try { const path = await invoke("pick_pdf"); if (path) loadDocument(path); } catch (e) { $("status").textContent = "Lỗi chọn file: " + e; } } let pageObserver; function buildPages() { const container = $("pages"); container.innerHTML = ""; state.slots = []; state.visible.clear(); for (const p of state.pages) { const slot = document.createElement("div"); slot.className = "page-slot"; slot.dataset.index = p.index; const { w, h } = displaySize(p); slot.style.width = w + "px"; slot.style.height = h + "px"; const ph = document.createElement("div"); ph.className = "ph"; ph.textContent = `Trang ${p.index + 1}`; slot.appendChild(ph); const overlay = document.createElement("div"); overlay.className = "overlay"; slot.appendChild(overlay); const annotlayer = document.createElement("div"); annotlayer.className = "annotlayer"; slot.appendChild(annotlayer); container.appendChild(slot); state.slots[p.index] = slot; } if (pageObserver) pageObserver.disconnect(); pageObserver = new IntersectionObserver(onIntersect, { root: $("viewport"), rootMargin: "500px 0px", threshold: 0.01, }); for (const slot of state.slots) pageObserver.observe(slot); $("viewport").scrollTop = 0; } function onIntersect(entries) { for (const e of entries) { const idx = Number(e.target.dataset.index); if (e.isIntersecting) { state.visible.add(idx); renderSlot(idx); } else { state.visible.delete(idx); } } updateCurrentPage(); } function zoomKey() { return state.zoom.toFixed(3); } async function renderSlot(idx) { const slot = state.slots[idx]; if (!slot) return; if (slot.dataset.renderedZoom === zoomKey()) return; // đã render ở zoom này slot.dataset.renderedZoom = zoomKey(); const p = state.pages[idx]; const renderWidth = Math.round(p.widthPt * PT_PER_PX * state.zoom * DPR); try { const dataUrl = await invoke("render_page", { path: state.path, page: idx, width: renderWidth, }); let img = slot.querySelector("img"); if (!img) { img = document.createElement("img"); slot.insertBefore(img, slot.firstChild); } img.src = dataUrl; const ph = slot.querySelector(".ph"); if (ph) ph.remove(); buildTextLayer(idx); drawAnnotsForPage(idx); } catch (e) { slot.dataset.renderedZoom = ""; // cho phép thử lại $("status").textContent = "Lỗi render trang " + (idx + 1) + ": " + e; } } // Dựng lớp text trong suốt cho phép chọn & copy text trên ảnh trang. async function buildTextLayer(idx) { const slot = state.slots[idx]; if (!slot) return; let layer = slot.querySelector(".textlayer"); if (!layer) { layer = document.createElement("div"); layer.className = "textlayer"; slot.appendChild(layer); } let boxes = state.textLayers[idx]; if (!boxes) { try { boxes = await invoke("page_text_layer", { path: state.path, page: idx }); state.textLayers[idx] = boxes; } catch { return; } } const p = state.pages[idx]; const scale = PT_PER_PX * state.zoom; layer.innerHTML = ""; const frag = document.createDocumentFragment(); // Gom ký tự thành "từ" (mỗi từ = 1 span) để double-click chọn cả từ, // kéo/Shift+Click chọn dải, và copy giữ khoảng trắng (chèn text node " "). let word = null; const flush = () => { if (word && word.text) { const w = (word.right - word.left) * scale; const h = (word.top - word.bottom) * scale; const span = document.createElement("span"); span.textContent = word.text; span.style.left = word.left * scale + "px"; span.style.top = (p.heightPt - word.top) * scale + "px"; span.style.width = Math.max(w, 1) + "px"; span.style.height = h + "px"; span.style.fontSize = h * 0.82 + "px"; span.style.lineHeight = h + "px"; frag.appendChild(span); } word = null; }; for (const b of boxes) { const isWs = !b.ch || /\s/.test(b.ch); if (isWs) { flush(); frag.appendChild(document.createTextNode(b.ch && /\n/.test(b.ch) ? "\n" : " ")); continue; } const h = b.top - b.bottom; if (h <= 0) continue; if (word) { const sameLine = Math.abs(b.bottom - word.bottom) <= 0.6 * (word.top - word.bottom); const gap = b.left - word.right; if (!sameLine || gap > 0.3 * (word.top - word.bottom)) { flush(); frag.appendChild(document.createTextNode(" ")); } } if (!word) { word = { text: b.ch, left: b.left, right: b.right, top: b.top, bottom: b.bottom }; } else { word.text += b.ch; word.left = Math.min(word.left, b.left); word.right = Math.max(word.right, b.right); word.top = Math.max(word.top, b.top); word.bottom = Math.min(word.bottom, b.bottom); } } flush(); layer.appendChild(frag); } function updateCurrentPage() { const vp = $("viewport"); const probe = vp.scrollTop + vp.clientHeight * 0.35; let cur = 0; for (const slot of state.slots) { if (!slot) continue; if (slot.offsetTop <= probe) cur = Number(slot.dataset.index); else break; } if (cur !== state.current) { state.current = cur; if (document.activeElement !== $("pageInput")) $("pageInput").value = cur + 1; document.querySelectorAll(".thumb").forEach((t) => { t.classList.toggle("active", Number(t.dataset.index) === cur); }); } } function updatePageTotal() { $("pageTotal").textContent = state.pages.length || "—"; $("pageInput").value = state.pages.length ? state.current + 1 : ""; } function goToPageInput() { const n = parseInt($("pageInput").value, 10); if (!Number.isFinite(n)) { $("pageInput").value = state.current + 1; return; } const idx = Math.max(0, Math.min(state.pages.length - 1, n - 1)); goToPage(idx); $("pageInput").value = idx + 1; } function gotoPrevPage() { goToPage(Math.max(0, state.current - 1)); } function gotoNextPage() { goToPage(Math.min(state.pages.length - 1, state.current + 1)); } // ---------- Thumbnails ---------- let thumbObserver; const THUMB_W = 130; function buildThumbnails() { const box = $("thumbs"); box.innerHTML = ""; if (thumbObserver) thumbObserver.disconnect(); // Lazy: chỉ render thumbnail khi cuộn tới (quan trọng với file 1000+ trang). thumbObserver = new IntersectionObserver(onThumbIntersect, { root: box, rootMargin: "400px 0px", threshold: 0.01, }); for (const p of state.pages) { const el = document.createElement("div"); el.className = "thumb"; el.dataset.index = p.index; // Giữ chỗ đúng tỉ lệ để placeholder có chiều cao (observer hoạt động đúng). const thumbH = Math.round(THUMB_W * (p.heightPt / p.widthPt)); el.style.minHeight = thumbH + 18 + "px"; el.innerHTML = `t${p.index}
${p.index + 1}
`; el.addEventListener("click", () => goToPage(p.index)); box.appendChild(el); thumbObserver.observe(el); } } function onThumbIntersect(entries) { for (const e of entries) { if (!e.isIntersecting) continue; const el = e.target; if (el.dataset.loaded) continue; el.dataset.loaded = "1"; const idx = Number(el.dataset.index); invoke("render_page", { path: state.path, page: idx, width: 150 }) .then((url) => { const img = el.querySelector("img"); img.src = url; img.style.height = ""; // trả lại auto theo tỉ lệ el.style.minHeight = ""; }) .catch(() => { el.dataset.loaded = ""; }); } } // ---------- Outline ---------- function buildOutline(items) { const box = $("outline"); box.innerHTML = ""; if (!items.length) { box.innerHTML = `
Không có outline
`; return; } for (const it of items) { const el = document.createElement("div"); el.className = "oitem"; el.style.paddingLeft = 6 + it.level * 14 + "px"; el.textContent = it.title || "(không tiêu đề)"; if (it.pageIndex != null) { el.addEventListener("click", () => goToPage(it.pageIndex)); } else { el.style.color = "#777"; } box.appendChild(el); } } // ---------- Điều hướng ---------- function goToPage(idx) { const slot = state.slots[idx]; if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" }); } // ---------- Zoom ---------- function setZoom(z) { finishEditing(); closeNotePopup(); closeColorPopover(); state.zoom = Math.max(0.25, Math.min(5, z)); // Phản hồi tức thì & mượt: chỉ đổi kích thước slot → ảnh sẵn có co giãn theo. for (const p of state.pages) { const slot = state.slots[p.index]; if (!slot) continue; const { w, h } = displaySize(p); slot.style.width = w + "px"; slot.style.height = h + "px"; } // Overlay/annotation rẻ → vẽ lại ngay để bám theo. drawHighlightsForVisible(); for (const idx of state.visible) drawAnnotsForPage(idx); updateZoomLabel(); // Render NÉT bằng PDFium chỉ sau khi ngừng zoom (tránh flood gây lag/crash). scheduleSharpRerender(); } let sharpTimer = null; function scheduleSharpRerender() { clearTimeout(sharpTimer); sharpTimer = setTimeout(() => { for (const idx of state.visible) { const slot = state.slots[idx]; if (slot) { slot.dataset.renderedZoom = ""; // buộc render lại ở zoom mới clearOverlay(slot); } renderSlot(idx); } drawHighlightsForVisible(); for (const idx of state.visible) drawAnnotsForPage(idx); }, 180); } const ZOOM_PRESETS = [50, 75, 100, 125, 150, 200, 300, 400]; function buildZoomSelect() { const sel = $("zoomSelect"); sel.innerHTML = ""; for (const pct of ZOOM_PRESETS) { const o = document.createElement("option"); o.value = pct; o.textContent = pct + "%"; sel.appendChild(o); } } function updateZoomLabel() { const sel = $("zoomSelect"); const pct = Math.round(state.zoom * 100); let custom = sel.querySelector('option[data-custom="1"]'); const matchesPreset = ZOOM_PRESETS.includes(pct); if (matchesPreset) { if (custom) custom.remove(); sel.value = String(pct); } else { if (!custom) { custom = document.createElement("option"); custom.dataset.custom = "1"; sel.appendChild(custom); } custom.value = String(pct); custom.textContent = pct + "%"; sel.value = String(pct); } } // Zoom giữ nguyên điểm dưới con trỏ chuột (Ctrl + lăn chuột). function zoomAtPoint(newZoom, clientX, clientY) { const vp = $("viewport"); const rect = vp.getBoundingClientRect(); const px = clientX - rect.left; const py = clientY - rect.top; const cx = vp.scrollLeft + px; const cy = vp.scrollTop + py; const old = state.zoom; setZoom(newZoom); const f = state.zoom / old; vp.scrollLeft = cx * f - px; vp.scrollTop = cy * f - py; } function fitWidth() { if (!state.pages.length) return; const avail = $("viewport").clientWidth - 48; const p = state.pages[state.current] || state.pages[0]; setZoom(avail / (p.widthPt * PT_PER_PX)); } function fitPage() { if (!state.pages.length) return; const vp = $("viewport"); const availW = vp.clientWidth - 48; const availH = vp.clientHeight - 48; const p = state.pages[state.current] || state.pages[0]; const zw = availW / (p.widthPt * PT_PER_PX); const zh = availH / (p.heightPt * PT_PER_PX); setZoom(Math.min(zw, zh)); } // ---------- Tìm kiếm ---------- let searchTimer; async function runSearch() { const q = $("searchBox").value.trim(); clearAllHighlights(); state.hits = []; state.hitIdx = -1; if (!q) { $("searchCount").textContent = "—"; return; } try { const hits = await invoke("search_document", { path: state.path, query: q, caseSensitive: $("searchCase").checked, }); state.hits = hits; $("searchCount").textContent = hits.length ? `0/${hits.length}` : "0"; if (hits.length) gotoHit(0); drawHighlightsForVisible(); } catch (e) { $("searchCount").textContent = "lỗi"; $("status").textContent = "Lỗi tìm kiếm: " + e; } } function gotoHit(i) { if (!state.hits.length) return; state.hitIdx = (i + state.hits.length) % state.hits.length; const hit = state.hits[state.hitIdx]; $("searchCount").textContent = `${state.hitIdx + 1}/${state.hits.length}`; goToPage(hit.pageIndex); // chờ cuộn rồi vẽ highlight setTimeout(() => { drawHighlightsForVisible(); }, 120); } function clearOverlay(slot) { const ov = slot.querySelector(".overlay"); if (ov) ov.innerHTML = ""; } function clearAllHighlights() { for (const slot of state.slots) if (slot) clearOverlay(slot); } function drawHighlightsForVisible() { clearAllHighlights(); for (const idx of state.visible) drawHighlightsForPage(idx); // luôn vẽ trang chứa hit hiện tại if (state.hitIdx >= 0) drawHighlightsForPage(state.hits[state.hitIdx].pageIndex); } function drawHighlightsForPage(pageIdx) { const slot = state.slots[pageIdx]; if (!slot) return; const p = state.pages[pageIdx]; const ov = slot.querySelector(".overlay"); if (!ov) return; const scale = PT_PER_PX * state.zoom; state.hits.forEach((h, i) => { if (h.pageIndex !== pageIdx || !h.rect) return; const r = h.rect; const div = document.createElement("div"); div.className = "hl" + (i === state.hitIdx ? " current" : ""); div.style.left = r.left * scale + "px"; div.style.top = (p.heightPt - r.top) * scale + "px"; div.style.width = (r.right - r.left) * scale + "px"; div.style.height = (r.top - r.bottom) * scale + "px"; ov.appendChild(div); }); } // ---------- Chú thích (annotations) ---------- function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function rgbCss(c) { return `rgb(${c[0]},${c[1]},${c[2]})`; } function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); } // Highlight/Underline/Strikeout bám theo TEXT đã chọn (như Foxit), không phải // 1 rectangle tự do — khác với Square/FreeText/Note (vẽ/đặt tự do trên trang). function isTextMarkupTool(tool) { return tool === "highlight" || tool === "underline" || tool === "strikeout"; } function setTool(tool) { state.tool = state.tool === tool ? null : tool; document.querySelectorAll(".atool").forEach((b) => b.classList.toggle("active", b.dataset.tool === state.tool) ); document.body.classList.toggle("tool-active", !!state.tool); document.body.classList.toggle("tool-mark", isTextMarkupTool(state.tool)); $("annotHint").textContent = state.tool ? state.tool === "note" ? "Bấm lên trang để đặt ghi chú" : state.tool === "redact" ? "Kéo chuột quét vùng cần bôi đen — nội dung sẽ bị XOÁ THẬT khi Áp dụng" : isTextMarkupTool(state.tool) ? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)" : "Kéo chuột trên trang để vẽ" : ""; } function updateAnnotCount() { $("annotCount").textContent = state.annotSpecs.length; $("saveAnnots").disabled = state.annotSpecs.length === 0; } // ===== Undo/Redo TOÀN CỤC (Ctrl+Z / Ctrl+Y) ===== // Snapshot {annotSpecs, pagePlan} trước mỗi hành động thay đổi (tạo/xoá/sửa // chú thích, chèn/xoá/xoay/đảo/crop trang...) — đơn giản & an toàn cho quy mô // dữ liệu của 1 tài liệu, không cần theo dõi diff từng trường. 1 stack DUY // NHẤT cho cả Annotate và Organize → Ctrl+Z hoạt động xuyên cả 2 chế độ. function snapshot() { return JSON.parse(JSON.stringify({ annotSpecs: state.annotSpecs, pagePlan: state.pagePlan })); } function pushUndo() { state.undoStack.push(snapshot()); if (state.undoStack.length > UNDO_LIMIT) state.undoStack.shift(); state.redoStack = []; updateUndoRedoButtons(); } function updateUndoRedoButtons() { $("undoBtn").disabled = state.undoStack.length === 0; $("redoBtn").disabled = state.redoStack.length === 0; } function redrawAllAnnotPages() { for (const slot of state.slots) { if (slot) drawAnnotsForPage(Number(slot.dataset.index)); } } function applySnapshot(snap) { finishEditing(); closeNotePopup(); state.annotSpecs = snap.annotSpecs; state.pagePlan = snap.pagePlan; state.selectedId = null; state.orgSelected = new Set(); redrawAllAnnotPages(); updateAnnotCount(); updateUndoRedoButtons(); buildComments(); if (state.organizeMode) buildOrganizeGrid(); } function undo() { if (!state.undoStack.length) return; const prev = state.undoStack.pop(); state.redoStack.push(snapshot()); applySnapshot(prev); } function redo() { if (!state.redoStack.length) return; const next = state.redoStack.pop(); state.undoStack.push(snapshot()); applySnapshot(next); } // Vẽ preview toàn bộ chú thích chưa lưu của 1 trang. function drawAnnotsForPage(idx) { const slot = state.slots[idx]; if (!slot) return; const layer = slot.querySelector(".annotlayer"); if (!layer) return; layer.innerHTML = ""; const p = state.pages[idx]; const scale = PT_PER_PX * state.zoom; const isMarkup = (k) => k === "highlight" || k === "underline" || k === "strikeout"; for (const s of state.annotSpecs) { if (s.pageIndex !== idx) continue; if (editing && editing.id === s.id) continue; // đang sửa thì bỏ qua preview const col = rgbCss(s.color); if (isMarkup(s.kind)) { // Mỗi quad (= 1 dòng text đã chọn) vẽ riêng — không phải 1 khối phủ cả // khoảng trắng giữa các dòng (đúng như Foxit). const quads = s.quads && s.quads.length ? s.quads : [{ left: s.left, bottom: s.bottom, right: s.right, top: s.top }]; quads.forEach((q, qi) => { const left = q.left * scale; const top = (p.heightPt - q.top) * scale; const w = (q.right - q.left) * scale; const h = (q.top - q.bottom) * scale; const el = document.createElement("div"); el.dataset.id = s.id; if (s.kind === "highlight") { el.className = "a-hl a-sel"; el.style.background = `rgba(${s.color[0]},${s.color[1]},${s.color[2]},.4)`; Object.assign(el.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" }); } else if (s.kind === "underline") { el.className = "a-line a-sel"; el.style.borderTopColor = col; Object.assign(el.style, { left: left + "px", top: top + h - 2 + "px", width: w + "px", height: "4px" }); } else { el.className = "a-line a-sel"; el.style.borderTopColor = col; Object.assign(el.style, { left: left + "px", top: top + h / 2 + "px", width: w + "px", height: "4px" }); } el.addEventListener("click", (ev) => { ev.stopPropagation(); selectAnnot(s.id); }); if (s.id === state.selectedId) { el.classList.add("selected"); if (qi === quads.length - 1) { const del = document.createElement("div"); del.className = "a-del"; del.textContent = "✕"; del.title = "Xoá (Delete)"; del.addEventListener("click", (ev) => { ev.stopPropagation(); deleteSpec(s.id); }); el.appendChild(del); } } layer.appendChild(el); }); continue; } const left = s.left * scale; const top = (p.heightPt - s.top) * scale; const w = (s.right - s.left) * scale; const h = (s.top - s.bottom) * scale; const el = document.createElement("div"); el.dataset.id = s.id; if (s.kind === "square") { el.className = "a-box a-sel"; el.style.borderColor = col; Object.assign(el.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" }); } else if (s.kind === "freetext") { el.className = "a-text a-sel"; el.style.borderColor = col; el.style.color = col; el.style.fontSize = (s.fontSize || 14) * scale + "px"; el.style.fontWeight = s.bold ? "bold" : "normal"; el.style.fontStyle = s.italic ? "italic" : "normal"; el.style.textDecoration = s.underline ? "underline" : "none"; el.textContent = s.contents || "Văn bản…"; Object.assign(el.style, { left: left + "px", top: top + "px", width: Math.max(w, 40) + "px", minHeight: h + "px" }); el.addEventListener("dblclick", (ev) => { ev.stopPropagation(); editTextBox(s); }); } else if (s.kind === "note") { el.className = "a-note a-sel"; el.textContent = "📝"; el.style.background = `rgb(${s.color[0]},${s.color[1]},${s.color[2]})`; Object.assign(el.style, { left: left + "px", top: top + "px" }); el.addEventListener("click", (ev) => { ev.stopPropagation(); openNotePopup(s); }); } if (s.kind !== "note") { el.addEventListener("click", (ev) => { ev.stopPropagation(); selectAnnot(s.id); }); } if (s.id === state.selectedId) { el.classList.add("selected"); const del = document.createElement("div"); del.className = "a-del"; del.textContent = "✕"; del.title = "Xoá (Delete)"; del.addEventListener("click", (ev) => { ev.stopPropagation(); deleteSpec(s.id); }); el.appendChild(del); } layer.appendChild(el); } // Đánh dấu redact (Phase 5) — vẽ cùng layer để sống sót qua mọi lần redraw. for (const m of state.redactMarks) { if (m.page !== idx) continue; const el = document.createElement("div"); el.className = "redact-mark"; Object.assign(el.style, { left: m.rect.left * scale + "px", top: (p.heightPt - m.rect.top) * scale + "px", width: (m.rect.right - m.rect.left) * scale + "px", height: (m.rect.top - m.rect.bottom) * scale + "px", }); el.title = "Bấm để bỏ đánh dấu redact"; el.addEventListener("click", (ev) => { ev.stopPropagation(); const i = state.redactMarks.indexOf(m); if (i >= 0) state.redactMarks.splice(i, 1); drawAnnotsForPage(idx); updateRedactButtons(); }); layer.appendChild(el); } } function selectAnnot(id) { state.selectedId = id; finishEditing(); redrawVisibleAnnots(); } function deselectAnnot() { if (state.selectedId == null) return; state.selectedId = null; redrawVisibleAnnots(); } function redrawVisibleAnnots() { for (const idx of state.visible) drawAnnotsForPage(idx); } function deleteSpec(id) { const i = state.annotSpecs.findIndex((s) => s.id === id); if (i < 0) return; pushUndo(); const pg = state.annotSpecs[i].pageIndex; state.annotSpecs.splice(i, 1); if (state.selectedId === id) state.selectedId = null; drawAnnotsForPage(pg); updateAnnotCount(); buildComments(); } function cssToPdf(idx, cssX, cssY) { const p = state.pages[idx]; const scale = PT_PER_PX * state.zoom; return { x: cssX / scale, y: p.heightPt - cssY / scale }; } // ===== Sửa Text box tại chỗ (in-place) + thanh Format ===== let editing = null; // { id, el, bar } function editTextBox(spec) { finishEditing(); pushUndo(); // ghi lại trạng thái TRƯỚC khi sửa nội dung/định dạng text box state.selectedId = null; state.tool = null; document.querySelectorAll(".atool").forEach((b) => b.classList.remove("active")); document.body.classList.remove("tool-active"); const slot = state.slots[spec.pageIndex]; const layer = slot.querySelector(".annotlayer"); const scale = PT_PER_PX * state.zoom; const left = spec.left * scale; const top = (state.pages[spec.pageIndex].heightPt - spec.top) * scale; const w = Math.max((spec.right - spec.left) * scale, 60); const h = Math.max((spec.top - spec.bottom) * scale, 20); const ed = document.createElement("div"); ed.className = "a-editor"; ed.contentEditable = "true"; ed.textContent = spec.contents || ""; Object.assign(ed.style, { left: left + "px", top: top + "px", width: w + "px", minHeight: h + "px", color: rgbCss(spec.color), fontSize: (spec.fontSize || 14) * scale + "px", fontWeight: spec.bold ? "bold" : "normal", fontStyle: spec.italic ? "italic" : "normal", textDecoration: spec.underline ? "underline" : "none", }); layer.appendChild(ed); drawAnnotsForPage(spec.pageIndex); // ẩn preview của spec đang sửa editing = { id: spec.id, el: ed }; showFmtBar(spec, ed); ed.focus(); // đặt con trỏ cuối const sel = window.getSelection(); sel.selectAllChildren(ed); sel.collapseToEnd(); ed.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); finishEditing(); } }); } function finishEditing() { if (!editing) return; const spec = state.annotSpecs.find((s) => s.id === editing.id); const ed = editing.el; if (spec && ed) { spec.contents = ed.innerText.trim(); // mở rộng rect theo chiều cao thực tế của editor const scale = PT_PER_PX * state.zoom; const realH = ed.offsetHeight / scale; spec.bottom = spec.top - Math.max(realH, 14); } if (ed && ed.parentNode) ed.remove(); if (editing.bar && editing.bar.parentNode) editing.bar.remove(); const pg = spec ? spec.pageIndex : null; editing = null; // Xoá text box rỗng (không nhập gì). if (spec && spec.kind === "freetext" && !spec.contents) { const i = state.annotSpecs.indexOf(spec); if (i >= 0) state.annotSpecs.splice(i, 1); } if (pg != null) drawAnnotsForPage(pg); updateAnnotCount(); buildComments(); } // Thanh định dạng nổi cho text box (size, B, I, U, màu) function showFmtBar(spec, editorEl) { const bar = document.createElement("div"); bar.className = "fmtbar"; const r = editorEl.getBoundingClientRect(); bar.style.left = r.left + "px"; bar.style.top = r.top - 40 + "px"; const sizes = [8, 10, 12, 14, 18, 24, 32, 48]; const sizeSel = document.createElement("select"); sizeSel.title = "Cỡ chữ"; for (const s of sizes) { const o = document.createElement("option"); o.value = s; o.textContent = s; if (s === (spec.fontSize || 14)) o.selected = true; sizeSel.appendChild(o); } sizeSel.addEventListener("change", () => { spec.fontSize = Number(sizeSel.value); editorEl.style.fontSize = spec.fontSize * PT_PER_PX * state.zoom + "px"; editorEl.focus(); }); const mkToggle = (label, prop, cssApply) => { const b = document.createElement("button"); b.textContent = label; b.className = "fmtbtn" + (spec[prop] ? " on" : ""); b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => { spec[prop] = !spec[prop]; b.classList.toggle("on", spec[prop]); cssApply(spec[prop]); editorEl.focus(); }); return b; }; bar.appendChild(sizeSel); bar.appendChild(mkToggle("B", "bold", (on) => editorEl.style.fontWeight = on ? "bold" : "normal")); const it = mkToggle("I", "italic", (on) => editorEl.style.fontStyle = on ? "italic" : "normal"); it.style.fontStyle = "italic"; bar.appendChild(it); const un = mkToggle("U", "underline", (on) => editorEl.style.textDecoration = on ? "underline" : "none"); un.style.textDecoration = "underline"; bar.appendChild(un); const colorBtn = document.createElement("button"); colorBtn.className = "fmtbtn fmt-color"; colorBtn.innerHTML = `A`; colorBtn.addEventListener("mousedown", (e) => e.preventDefault()); colorBtn.addEventListener("click", () => { openColorPopover(colorBtn, spec.color, (rgb) => { spec.color = rgb; editorEl.style.color = rgbCss(rgb); colorBtn.querySelector(".sw").style.background = rgbCss(rgb); editorEl.focus(); }); }); bar.appendChild(colorBtn); document.body.appendChild(bar); editing.bar = bar; } // ===== Note popup (xem/sửa/đổi màu/xoá) ===== let notePopupEl = null; function openNotePopup(spec) { closeNotePopup(); pushUndo(); // ghi lại trạng thái TRƯỚC khi sửa nội dung/màu ghi chú const slot = state.slots[spec.pageIndex]; const layer = slot.querySelector(".annotlayer"); const icon = layer.querySelector(`.a-note[data-id="${spec.id}"]`); const r = (icon || slot).getBoundingClientRect(); const pop = document.createElement("div"); pop.className = "notepop"; pop.style.left = r.right + 6 + "px"; pop.style.top = r.top + "px"; pop.innerHTML = `
Ghi chú✕
`; document.body.appendChild(pop); notePopupEl = pop; const ta = pop.querySelector(".np-text"); ta.addEventListener("input", () => { spec.contents = ta.value; buildComments(); }); pop.querySelector(".np-close").addEventListener("click", closeNotePopup); pop.querySelector(".np-del").addEventListener("click", () => { closeNotePopup(); deleteSpec(spec.id); }); pop.querySelector(".np-color").addEventListener("click", (e) => { openColorPopover(e.currentTarget, spec.color, (rgb) => { spec.color = rgb; pop.querySelector(".np-color .sw").style.background = rgbCss(rgb); drawAnnotsForPage(spec.pageIndex); }); }); ta.focus(); } function closeNotePopup() { if (notePopupEl) { notePopupEl.remove(); notePopupEl = null; } } // ===== Color popover (preset + gần đây + tuỳ chọn) ===== let colorPopEl = null; function openColorPopover(anchor, currentRgb, onPick) { closeColorPopover(); const r = anchor.getBoundingClientRect(); const pop = document.createElement("div"); pop.className = "colorpop"; pop.style.left = r.left + "px"; pop.style.top = r.bottom + 4 + "px"; const addSwatch = (rgb, container) => { const s = document.createElement("div"); s.className = "cs"; s.style.background = rgbCss(rgb); if (rgb[0] === currentRgb[0] && rgb[1] === currentRgb[1] && rgb[2] === currentRgb[2]) s.classList.add("cur"); s.addEventListener("click", () => { pushRecentColor(rgb); onPick(rgb.slice()); closeColorPopover(); }); container.appendChild(s); }; const grid = document.createElement("div"); grid.className = "cgrid"; for (const c of PRESET_COLORS) addSwatch(c, grid); pop.appendChild(grid); if (state.recentColors.length) { const lbl = document.createElement("div"); lbl.className = "clbl"; lbl.textContent = "Gần đây"; pop.appendChild(lbl); const rg = document.createElement("div"); rg.className = "cgrid"; for (const c of state.recentColors) addSwatch(c, rg); pop.appendChild(rg); } const custom = document.createElement("label"); custom.className = "ccustom"; custom.innerHTML = `Tuỳ chọn… `; custom.querySelector("input").addEventListener("input", (e) => { const rgb = hexToRgb(e.target.value); pushRecentColor(rgb); onPick(rgb); }); custom.querySelector("input").addEventListener("change", closeColorPopover); pop.appendChild(custom); document.body.appendChild(pop); colorPopEl = pop; } function closeColorPopover() { if (colorPopEl) { colorPopEl.remove(); colorPopEl = null; } } function pushRecentColor(rgb) { state.recentColors = state.recentColors.filter((c) => !(c[0] === rgb[0] && c[1] === rgb[1] && c[2] === rgb[2])); state.recentColors.unshift(rgb.slice()); state.recentColors = state.recentColors.slice(0, 8); } function rgbToHex(c) { return "#" + c.map((v) => v.toString(16).padStart(2, "0")).join(""); } let drag = null; function onPagesMouseDown(e) { if (!state.tool) { // click vùng trống: bỏ chọn + đóng popup + kết thúc sửa if (!e.target.closest(".a-editor")) { finishEditing(); closeNotePopup(); closeColorPopover(); if (!e.target.dataset || e.target.dataset.id == null) deselectAnnot(); } return; } // Highlight/Underline/Strikeout: để trình duyệt tự xử lý chọn text (textlayer // pointer-events đã mở qua .tool-mark) — tạo annotation ở mouseup từ Selection. if (isTextMarkupTool(state.tool)) return; const slot = e.target.closest && e.target.closest(".page-slot"); if (!slot) return; e.preventDefault(); const r = slot.getBoundingClientRect(); drag = { idx: Number(slot.dataset.index), x0: e.clientX - r.left, y0: e.clientY - r.top, x1: e.clientX - r.left, y1: e.clientY - r.top, }; } function onPagesMouseMove(e) { if (!drag) return; const slot = state.slots[drag.idx]; const r = slot.getBoundingClientRect(); drag.x1 = e.clientX - r.left; drag.y1 = e.clientY - r.top; const layer = slot.querySelector(".annotlayer"); let prev = layer.querySelector(".a-preview"); if (!prev) { prev = document.createElement("div"); prev.className = "a-hl a-preview"; layer.appendChild(prev); } const l = Math.min(drag.x0, drag.x1); const t = Math.min(drag.y0, drag.y1); prev.style.cssText = `left:${l}px;top:${t}px;width:${Math.abs(drag.x1 - drag.x0)}px;height:${Math.abs(drag.y1 - drag.y0)}px;` + `background:rgba(${state.color[0]},${state.color[1]},${state.color[2]},.3)`; } // Mỗi "từ" trong text layer là 1 định vị tuyệt đối riêng (để bám đúng // pixel PDFium) — vì vậy Range.getClientRects() trả về 1 rect/TỪ, không phải // 1 rect/DÒNG như với text chảy bình thường. Gộp các rect chồng lấp theo // chiều dọc (cùng 1 dòng) thành 1 rect duy nhất, để highlight 1 cụm từ liền // mạch chứ không bị đứt từng từ với khoảng trắng hở giữa. function mergeRectsIntoLines(rects) { const sorted = rects.slice().sort((a, b) => a.top - b.top); const lines = []; for (const r of sorted) { const line = lines.find((l) => { const overlap = Math.min(l.bottom, r.bottom) - Math.max(l.top, r.top); return overlap > 0.5 * Math.min(l.bottom - l.top, r.bottom - r.top); }); if (line) { line.left = Math.min(line.left, r.left); line.right = Math.max(line.right, r.right); line.top = Math.min(line.top, r.top); line.bottom = Math.max(line.bottom, r.bottom); } else { lines.push({ left: r.left, right: r.right, top: r.top, bottom: r.bottom }); } } return lines; } // Tạo Highlight/Underline/Strikeout từ vùng TEXT người dùng vừa chọn (Selection // API) — bám đúng từng dòng như Foxit, không phải 1 rectangle tự do. function createMarkupFromSelection() { const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const anchorNode = range.commonAncestorContainer; const anchorEl = anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentElement; const slot = anchorEl && anchorEl.closest(".page-slot"); if (!slot) { sel.removeAllRanges(); return; } const idx = Number(slot.dataset.index); const slotRect = slot.getBoundingClientRect(); const rawRects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5); sel.removeAllRanges(); if (!rawRects.length) return; const clientRects = mergeRectsIntoLines(rawRects); const quads = clientRects.map((r) => { const a = cssToPdf(idx, r.left - slotRect.left, r.top - slotRect.top); const b = cssToPdf(idx, r.right - slotRect.left, r.bottom - slotRect.top); return { left: Math.min(a.x, b.x), right: Math.max(a.x, b.x), bottom: Math.min(a.y, b.y), top: Math.max(a.y, b.y), }; }); const bounds = quads.reduce( (acc, q) => acc ? { left: Math.min(acc.left, q.left), right: Math.max(acc.right, q.right), bottom: Math.min(acc.bottom, q.bottom), top: Math.max(acc.top, q.top), } : { ...q }, null ); const spec = { id: annotIdSeq++, kind: state.tool, pageIndex: idx, left: bounds.left, bottom: bounds.bottom, right: bounds.right, top: bounds.top, quads, color: [...state.color], contents: "", fontSize: 14, bold: false, italic: false, underline: false, }; pushUndo(); state.annotSpecs.push(spec); pushRecentColor(state.color); // Giữ tool active để tô tiếp liên tục (như Foxit) — bấm lại nút hoặc Esc để tắt. drawAnnotsForPage(idx); updateAnnotCount(); buildComments(); } function onPagesMouseUp() { if (state.tool && isTextMarkupTool(state.tool)) { createMarkupFromSelection(); return; } if (!drag) return; const d = drag; drag = null; const slot = state.slots[d.idx]; const prev = slot.querySelector(".a-preview"); if (prev) prev.remove(); const a = cssToPdf(d.idx, d.x0, d.y0); const b = cssToPdf(d.idx, d.x1, d.y1); const rect = { left: Math.min(a.x, b.x), right: Math.max(a.x, b.x), bottom: Math.min(a.y, b.y), top: Math.max(a.y, b.y), }; const tool = state.tool; const scale = PT_PER_PX * state.zoom; const tiny = rect.right - rect.left < 2 || rect.top - rect.bottom < 2; if (tool === "crop") { if (tiny) return; openCropDialog(d.idx, rect); return; } if (tool === "redact") { if (tiny) return; state.redactMarks.push({ page: d.idx, rect }); drawAnnotsForPage(d.idx); updateRedactButtons(); return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction) } if (tool === "note") { const sz = 18 / scale; rect.right = rect.left + sz; rect.bottom = rect.top - sz; } else if (tool === "freetext") { if (tiny) { rect.right = rect.left + 160 / scale; rect.top = rect.bottom + 24 / scale; } } else if (tiny) { return; } const spec = { id: annotIdSeq++, kind: tool, pageIndex: d.idx, left: rect.left, bottom: rect.bottom, right: rect.right, top: rect.top, color: [...state.color], contents: "", fontSize: 14, bold: false, italic: false, underline: false, }; pushUndo(); state.annotSpecs.push(spec); pushRecentColor(state.color); // FreeText/Note: gõ ngay → về chế độ chọn (như Foxit). Square: giữ tool active // để vẽ tiếp liên tục. if (tool === "freetext" || tool === "note") setTool(null); drawAnnotsForPage(d.idx); updateAnnotCount(); buildComments(); if (tool === "freetext") editTextBox(spec); else if (tool === "note") openNotePopup(spec); } async function saveAnnots() { if (!state.annotSpecs.length) return; finishEditing(); const out = await invoke("pick_save_pdf"); if (!out) return; try { const specs = state.annotSpecs.map((s) => ({ kind: s.kind, pageIndex: s.pageIndex, left: s.left, bottom: s.bottom, right: s.right, top: s.top, quads: s.quads || [], color: [s.color[0], s.color[1], s.color[2], 255], contents: s.contents || null, fontSize: s.fontSize || 14, bold: !!s.bold, italic: !!s.italic, underline: !!s.underline, })); await invoke("apply_annotations", { input: state.path, output: out, specs }); const n = state.annotSpecs.length; state.annotSpecs = []; updateAnnotCount(); $("status").textContent = `Đã lưu ${n} chú thích → ${shortName(out)}`; loadDocument(out); } catch (e) { $("status").textContent = "Lỗi lưu chú thích: " + e; } } function buildComments() { const box = $("comments"); box.innerHTML = ""; if (!state.annotSpecs.length) { box.innerHTML = `
Chưa có chú thích chưa lưu. Chọn công cụ ở thanh trên và vẽ lên trang, rồi bấm "Lưu chú thích".
`; return; } const labels = { highlight: "Tô sáng", underline: "Gạch chân", strikeout: "Gạch ngang", square: "Khung", freetext: "Text box", note: "Ghi chú", }; for (const s of state.annotSpecs) { const el = document.createElement("div"); el.className = "citem"; el.innerHTML = `✕` + `` + `${labels[s.kind] || s.kind} · trang ${s.pageIndex + 1}` + (s.contents ? `
${escapeHtml(s.contents)}` : ""); el.addEventListener("click", (ev) => { if (ev.target.classList.contains("cdel")) { deleteSpec(s.id); } else { goToPage(s.pageIndex); selectAnnot(s.id); if (s.kind === "note") openNotePopup(s); } }); box.appendChild(el); } } // ---------- Copy ---------- async function copyCurrentPage() { try { const txt = await invoke("page_text", { path: state.path, page: state.current }); await navigator.clipboard.writeText(txt); $("status").textContent = `Đã copy text trang ${state.current + 1} (${txt.length} ký tự)`; } catch (e) { $("status").textContent = "Lỗi copy: " + e; } } // ---------- Phase 3: Tổ chức trang ---------- function openModal(title, bodyHtml) { $("modalBox").innerHTML = `

${title}

${bodyHtml}`; $("modalOverlay").classList.remove("hidden"); return $("modalBox"); } function closeModal() { $("modalOverlay").classList.add("hidden"); $("modalBox").innerHTML = ""; } /// Parse "1-3,5" (1-based, kiểu người dùng nhập) → mảng index 0-based hợp lệ. function parsePageRange(str, count) { const out = []; for (const part of str.split(",")) { const t = part.trim(); if (!t) continue; const m = t.match(/^(\d+)(?:-(\d+))?$/); if (!m) continue; const a = Number(m[1]); const b = m[2] ? Number(m[2]) : a; for (let n = Math.min(a, b); n <= Math.max(a, b); n++) { if (n >= 1 && n <= count) out.push(n - 1); } } return out; } function enterOrganizeMode() { state.organizeMode = true; $("annobar").classList.add("hidden"); $("organizeBar").classList.remove("hidden"); $("viewport").classList.add("hidden"); $("organizeGrid").classList.remove("hidden"); $("sidebar").classList.add("hidden"); $("organizeModeBtn").classList.add("active"); setTool(null); buildOrganizeGrid(); } function exitOrganizeMode() { state.organizeMode = false; $("annobar").classList.remove("hidden"); $("organizeBar").classList.add("hidden"); $("viewport").classList.remove("hidden"); $("organizeGrid").classList.add("hidden"); $("sidebar").classList.remove("hidden"); $("organizeModeBtn").classList.remove("active"); } function toggleOrganizeMode() { if (state.organizeMode) exitOrganizeMode(); else enterOrganizeMode(); } function orgSelectCard(i, e) { if (e.shiftKey && state.orgAnchor != null) { const a = Math.min(state.orgAnchor, i); const b = Math.max(state.orgAnchor, i); state.orgSelected = new Set(); for (let k = a; k <= b; k++) state.orgSelected.add(k); } else if (e.ctrlKey || e.metaKey) { if (state.orgSelected.has(i)) state.orgSelected.delete(i); else state.orgSelected.add(i); state.orgAnchor = i; } else { state.orgSelected = new Set([i]); state.orgAnchor = i; } // Chỉ đổi class — KHÔNG dựng lại lưới (tránh render lại toàn bộ thumbnail). refreshOrgSelection(); } // Chỉ cập nhật trạng thái chọn (viền xanh) trên các card hiện có — không đụng // tới thumbnail. Tách khỏi buildOrganizeGrid để click chọn không gây render lại. function refreshOrgSelection() { const cards = $("organizeGrid").children; for (let i = 0; i < cards.length; i++) { cards[i].classList.toggle("selected", state.orgSelected.has(i)); } } // pagePlan có khác trạng thái gốc của file đang mở không (đã chèn/xoá/đảo/xoay/ // crop)? Watermark/Header-Footer cần biết để áp đúng lên trạng thái đang xem, // không phải file gốc còn trên đĩa (xem materializeBaseInput). function planIsDirty() { if (state.pagePlan.length !== state.pages.length) return true; return state.pagePlan.some((e, i) => e.kind !== "existing" || e.source || e.srcIndex !== i || (e.rotationDelta || 0) !== 0 || e.crop); } // Trả về đường dẫn file nên dùng làm "input" cho Watermark/Header-Footer: // nếu pagePlan đang giữ nguyên trạng thái gốc → dùng thẳng state.path; nếu // đang có thay đổi tổ chức trang chưa lưu (chèn/xoá/đảo/xoay/crop) → dựng tạm // 1 file theo ĐÚNG trạng thái đang xem trên lưới (qua organize_materialize, // dùng lại build_document đã có) rồi dùng file đó — để watermark/đánh số thấy // đúng những gì người dùng đang thấy, không cần ép họ lưu rồi mở lại. async function materializeBaseInput() { if (!planIsDirty()) return { path: state.path, isTemp: false, pageCount: state.pagePlan.length }; const path = await invoke("organize_materialize", { mainInput: state.path, plan: state.pagePlan, password: null, }); return { path, isTemp: true, pageCount: state.pagePlan.length }; } const MATERIALIZED_NOTE = `

Đang dùng bản xem trước đã gồm các thay đổi tổ chức trang ` + `chưa lưu (chèn/xoá/đảo/xoay/crop) — chưa ghi đè file gốc.

`; function orgMovePage(from, to) { if (from === to) return; pushUndo(); const [moved] = state.pagePlan.splice(from, 1); state.pagePlan.splice(to, 0, moved); state.orgSelected = new Set(); state.orgAnchor = null; buildOrganizeGrid(); } // Khoá cache thumbnail: 1 ảnh render chỉ phụ thuộc nguồn + trang gốc (rotation // áp bằng CSS transform, crop chỉ là dấu chấm — đều không cần render lại). function orgThumbKey(entry) { return `${entry.source || ""}#${entry.srcIndex}`; } // Dựng lại toàn bộ lưới (chỉ gọi khi CẤU TRÚC plan đổi: chèn/xoá/đảo). Thumbnail // lấy từ cache nếu có; chỉ render trang chưa từng render → click chọn/xoay không // còn kéo theo render lại toàn bộ. function buildOrganizeGrid() { const box = $("organizeGrid"); box.innerHTML = ""; state.pagePlan.forEach((entry, i) => { const dims = (!entry.source && state.pages[entry.srcIndex]) ? state.pages[entry.srcIndex] : { widthPt: entry.widthPt || 612, heightPt: entry.heightPt || 792 }; const card = document.createElement("div"); card.className = "org-card" + (state.orgSelected.has(i) ? " selected" : ""); card.draggable = true; card.dataset.index = String(i); const wrapH = Math.round(160 * (dims.heightPt / dims.widthPt)); card.innerHTML = `
` + (entry.kind === "blank" ? `
` : `p${i}`) + (entry.crop ? `
` : "") + `
${i + 1}
`; const img = card.querySelector("img"); if (img) { const rot = entry.rotationDelta || 0; if (rot) img.style.transform = `rotate(${rot}deg)`; const key = orgThumbKey(entry); const cached = state.orgThumbs.get(key); if (cached) { img.src = cached; } else { const path = entry.source || state.path; invoke("render_page", { path, page: entry.srcIndex, width: 160 }) .then((url) => { state.orgThumbs.set(key, url); img.src = url; }) .catch(() => {}); } } card.addEventListener("click", (e) => orgSelectCard(i, e)); card.addEventListener("dragstart", (e) => e.dataTransfer.setData("text/plain", String(i))); card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("drag-over"); }); card.addEventListener("dragleave", () => card.classList.remove("drag-over")); card.addEventListener("drop", (e) => { e.preventDefault(); card.classList.remove("drag-over"); const from = Number(e.dataTransfer.getData("text/plain")); orgMovePage(from, i); }); box.appendChild(card); }); $("orgSave").disabled = state.pagePlan.length === 0; } function orgDeleteSelected() { if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để xoá"; return; } if (state.orgSelected.size >= state.pagePlan.length) { $("organizeHint").textContent = "Không thể xoá hết toàn bộ trang"; return; } pushUndo(); state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i)); state.orgSelected = new Set(); $("organizeHint").textContent = ""; buildOrganizeGrid(); } function orgRotateSelected(delta) { if (!state.pagePlan.length) return; pushUndo(); const targets = state.orgSelected.size ? state.orgSelected : new Set(state.pagePlan.map((_, i) => i)); const cards = $("organizeGrid").children; for (const i of targets) { const entry = state.pagePlan[i]; entry.rotationDelta = ((entry.rotationDelta || 0) + delta + 360) % 360; // Chỉ xoay ảnh bằng CSS — KHÔNG render lại trang. const img = cards[i] && cards[i].querySelector("img"); if (img) img.style.transform = entry.rotationDelta ? `rotate(${entry.rotationDelta}deg)` : ""; } } async function orgSaveChanges() { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("organize_apply", { mainInput: state.path, plan: state.pagePlan, output: out, password: null }); $("status").textContent = `Đã lưu thay đổi tổ chức trang → ${shortName(out)}`; exitOrganizeMode(); loadDocument(out); } catch (e) { $("organizeHint").textContent = "Lỗi lưu: " + e; } } function openInsertDialog() { const box = openModal("Chèn trang", `
`); let insertFile = null; box.querySelectorAll('input[name=insKind]').forEach((r) => r.addEventListener("change", () => { const isFile = box.querySelector('input[name=insKind]:checked').value === "file"; box.querySelector("#insBlankOpts").style.display = isFile ? "none" : ""; box.querySelector("#insFileOpts").style.display = isFile ? "" : "none"; })); box.querySelector("#insPaper").addEventListener("change", (e) => { box.querySelector("#insCustomSize").style.display = e.target.value === "custom" ? "flex" : "none"; }); box.querySelector("#insPickFile").addEventListener("click", async () => { const p = await invoke("pick_pdf"); if (p) { insertFile = p; box.querySelector("#insFileName").textContent = shortName(p); } }); box.querySelector("#insCancel").addEventListener("click", closeModal); box.querySelector("#insOk").addEventListener("click", async () => { const kind = box.querySelector('input[name=insKind]:checked').value; const pos = box.querySelector('input[name=insPos]:checked').value; let newEntries = []; if (kind === "blank") { let w = 612, h = 792; const paper = box.querySelector("#insPaper").value; if (paper === "custom") { w = Number(box.querySelector("#insW").value) || 612; h = Number(box.querySelector("#insH").value) || 792; } else { [w, h] = paper.split("x").map(Number); } newEntries = [{ kind: "blank", widthPt: w, heightPt: h, rotationDelta: 0, crop: null }]; } else { if (!insertFile) { box.querySelector("#insErr").textContent = "Hãy chọn file nguồn."; return; } let count; try { const meta = await invoke("open_document", { path: insertFile }); count = meta.pageCount; } catch (e) { box.querySelector("#insErr").textContent = "Không mở được file: " + e; return; } const rangeStr = box.querySelector("#insRange").value.trim(); const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i); if (!indices.length) { box.querySelector("#insErr").textContent = "Phạm vi trang không hợp lệ."; return; } newEntries = indices.map((idx) => ({ kind: "existing", source: insertFile, srcIndex: idx, rotationDelta: 0, crop: null })); } let at = state.pagePlan.length; if (pos === "before") at = Math.min(...state.orgSelected); else if (pos === "after") at = Math.max(...state.orgSelected) + 1; pushUndo(); state.pagePlan.splice(at, 0, ...newEntries); state.orgSelected = new Set(); buildOrganizeGrid(); closeModal(); }); } function openExtractDialog() { if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để trích"; return; } const box = openModal("Trích trang", `

Trích ${state.orgSelected.size} trang đã chọn ra file PDF mới.

`); box.querySelector("#extCancel").addEventListener("click", closeModal); box.querySelector("#extOk").addEventListener("click", async () => { const out = await invoke("pick_save_pdf"); if (!out) return; const indices = Array.from(state.orgSelected).sort((a, b) => a - b); const plan = indices.map((i) => state.pagePlan[i]); try { await invoke("organize_apply", { mainInput: state.path, plan, output: out, password: null }); if (box.querySelector("#extDeleteAfter").checked) { pushUndo(); state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i)); state.orgSelected = new Set(); buildOrganizeGrid(); } $("organizeHint").textContent = `Đã trích ra ${shortName(out)}`; closeModal(); } catch (e) { box.querySelector("#extErr").textContent = "Lỗi: " + e; } }); } function openReplaceDialog() { if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để thay"; return; } const box = openModal("Thay trang", `

Thay nội dung ${state.orgSelected.size} trang đã chọn bằng trang từ file khác.

`); let file = null; box.querySelector("#repPickFile").addEventListener("click", async () => { const p = await invoke("pick_pdf"); if (p) { file = p; box.querySelector("#repFileName").textContent = shortName(p); } }); box.querySelector("#repCancel").addEventListener("click", closeModal); box.querySelector("#repOk").addEventListener("click", async () => { if (!file) { box.querySelector("#repErr").textContent = "Hãy chọn file nguồn."; return; } let count; try { const meta = await invoke("open_document", { path: file }); count = meta.pageCount; } catch (e) { box.querySelector("#repErr").textContent = "Không mở được file: " + e; return; } const rangeStr = box.querySelector("#repRange").value.trim(); const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i); if (!indices.length) { box.querySelector("#repErr").textContent = "Phạm vi trang không hợp lệ."; return; } const newEntries = indices.map((idx) => ({ kind: "existing", source: file, srcIndex: idx, rotationDelta: 0, crop: null })); const sorted = Array.from(state.orgSelected).sort((a, b) => a - b); const at = sorted[0]; pushUndo(); state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i)); state.pagePlan.splice(Math.min(at, state.pagePlan.length), 0, ...newEntries); state.orgSelected = new Set(); buildOrganizeGrid(); closeModal(); }); } function openMergeDialog() { let files = []; const box = openModal("Trộn file PDF", `
`); function renderList() { box.querySelector("#mrgList").innerHTML = files.map((f, i) => `
` + `${i + 1}. ${shortName(f)}` + `` + `` + `
` ).join(""); } box.querySelector("#mrgAdd").addEventListener("click", async () => { const p = await invoke("pick_pdf"); if (p) { files.push(p); renderList(); } }); box.querySelector("#mrgList").addEventListener("click", (e) => { const t = e.target; if (t.dataset.up != null) { const i = Number(t.dataset.up); [files[i - 1], files[i]] = [files[i], files[i - 1]]; renderList(); } else if (t.dataset.down != null) { const i = Number(t.dataset.down); [files[i + 1], files[i]] = [files[i], files[i + 1]]; renderList(); } else if (t.dataset.rm != null) { files.splice(Number(t.dataset.rm), 1); renderList(); } }); box.querySelector("#mrgCancel").addEventListener("click", closeModal); box.querySelector("#mrgOk").addEventListener("click", async () => { if (files.length < 1) { box.querySelector("#mrgErr").textContent = "Hãy thêm ít nhất 1 file."; return; } const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("organize_merge", { files, output: out }); $("status").textContent = `Đã trộn ${files.length} file → ${shortName(out)}`; closeModal(); } catch (e) { box.querySelector("#mrgErr").textContent = "Lỗi: " + e; } }); renderList(); } function openSplitDialog() { const box = openModal("Tách file PDF", `
`); box.querySelector("#splitCancel").addEventListener("click", closeModal); box.querySelector("#splitOk").addEventListener("click", async () => { const n = Math.max(1, Number(box.querySelector("#splitN").value) || 1); const outDir = await invoke("pick_dir"); if (!outDir) return; try { const base = shortName(state.path).replace(/\.pdf$/i, ""); const outs = await invoke("organize_split", { input: state.path, pagesPerFile: n, outDir, baseName: base, password: null, }); $("status").textContent = `Đã tách thành ${outs.length} file vào ${outDir}`; closeModal(); } catch (e) { box.querySelector("#splitErr").textContent = "Lỗi: " + e; } }); } async function openWatermarkDialog() { const base = await materializeBaseInput(); const previewPage = base.isTemp ? (state.orgSelected.size ? Math.min(...state.orgSelected) : 0) : state.current; const box = openModal("Watermark", ` ${base.isTemp ? MATERIALIZED_NOTE : ""}
${["top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right"] .map((a) => ``).join("")}
`); let anchor = "center"; box.querySelector("#wmAnchor").addEventListener("click", (e) => { const btn = e.target.closest("button[data-a]"); if (!btn) return; anchor = btn.dataset.a; box.querySelectorAll("#wmAnchor button").forEach((b) => b.classList.toggle("cur", b === btn)); }); function buildSpec() { const [r, g, b] = box.querySelector("#wmColor").value.split(",").map((x) => Number(x.trim()) || 0); return { text: box.querySelector("#wmText").value || "", fontSize: Number(box.querySelector("#wmSize").value) || 36, color: [r, g, b, Number(box.querySelector("#wmAlpha").value) || 120], bold: box.querySelector("#wmBold").checked, italic: box.querySelector("#wmItalic").checked, rotationDeg: Number(box.querySelector("#wmRotate").value) || 0, anchor, pages: box.querySelector("#wmPages").value.trim() ? parsePageRange(box.querySelector("#wmPages").value.trim(), base.pageCount) : [], }; } box.querySelector("#wmPreview").addEventListener("click", async () => { try { const url = await invoke("preview_watermark", { input: base.path, page: previewPage, spec: buildSpec(), width: 500 }); let img = box.querySelector("#wmPreviewImg"); if (!img) { img = document.createElement("img"); img.id = "wmPreviewImg"; img.style.maxWidth = "100%"; img.style.marginTop = "8px"; box.insertBefore(img, box.querySelector(".foot")); } img.src = url; } catch (e) { box.querySelector("#wmErr").textContent = "Lỗi xem trước: " + e; } }); box.querySelector("#wmCancel").addEventListener("click", closeModal); box.querySelector("#wmOk").addEventListener("click", async () => { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("watermark_add", { input: base.path, spec: buildSpec(), output: out, password: null }); $("status").textContent = `Đã thêm watermark → ${shortName(out)}`; closeModal(); loadDocument(out); } catch (e) { box.querySelector("#wmErr").textContent = "Lỗi: " + e; } }); } async function openHeaderFooterDialog() { const base = await materializeBaseInput(); const previewPage = base.isTemp ? (state.orgSelected.size ? Math.min(...state.orgSelected) : 0) : state.current; const box = openModal("Header / Footer", ` ${base.isTemp ? MATERIALIZED_NOTE : ""}

Chèn vào ô đang focus:

`); let lastFocused = box.querySelector("#hfBC"); box.querySelectorAll("input[type=text]").forEach((inp) => inp.addEventListener("focus", () => { lastFocused = inp; })); box.querySelectorAll("button[data-tok]").forEach((btn) => btn.addEventListener("click", () => { if (lastFocused) { lastFocused.value += btn.dataset.tok; lastFocused.focus(); } })); function buildSpec() { const [r, g, b] = box.querySelector("#hfColor").value.split(",").map((x) => Number(x.trim()) || 0); return { topLeft: box.querySelector("#hfTL").value, topCenter: box.querySelector("#hfTC").value, topRight: box.querySelector("#hfTR").value, bottomLeft: box.querySelector("#hfBL").value, bottomCenter: box.querySelector("#hfBC").value, bottomRight: box.querySelector("#hfBR").value, fontSize: Number(box.querySelector("#hfSize").value) || 10, color: [r, g, b, 255], marginPt: Number(box.querySelector("#hfMargin").value) || 20, date: new Date().toLocaleDateString("vi-VN"), pages: box.querySelector("#hfPages").value.trim() ? parsePageRange(box.querySelector("#hfPages").value.trim(), base.pageCount) : [], }; } box.querySelector("#hfPreview").addEventListener("click", async () => { try { const url = await invoke("preview_header_footer", { input: base.path, page: previewPage, spec: buildSpec(), width: 500 }); let img = box.querySelector("#hfPreviewImg"); if (!img) { img = document.createElement("img"); img.id = "hfPreviewImg"; img.style.maxWidth = "100%"; img.style.marginTop = "8px"; box.insertBefore(img, box.querySelector(".foot")); } img.src = url; } catch (e) { box.querySelector("#hfErr").textContent = "Lỗi xem trước: " + e; } }); box.querySelector("#hfCancel").addEventListener("click", closeModal); box.querySelector("#hfOk").addEventListener("click", async () => { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("header_footer_add", { input: base.path, spec: buildSpec(), output: out, password: null }); $("status").textContent = `Đã thêm header/footer → ${shortName(out)}`; closeModal(); loadDocument(out); } catch (e) { box.querySelector("#hfErr").textContent = "Lỗi: " + e; } }); } // Crop: tái dùng cơ chế kéo-vẽ hình chữ nhật đã có cho tool "square" — xem // nhánh `tool === "crop"` trong onPagesMouseUp. Tìm theo `srcIndex` (không // theo vị trí trong pagePlan) vì viewer luôn hiển thị trang theo số trang GỐC // của file đang mở, bất kể pagePlan đã bị đảo/chèn/xoá hay chưa. function openCropDialog(pageIdx, rectPdf) { const p = state.pages[pageIdx]; const m = { left: rectPdf.left, bottom: rectPdf.bottom, right: p.widthPt - rectPdf.right, top: p.heightPt - rectPdf.top, }; const box = openModal("Crop trang", `

Áp dụng ngay vào kế hoạch tổ chức trang — vào "🗂 Tổ chức trang" → "💾 Lưu thay đổi" để ghi ra file thật.

`); box.querySelector("#cropCancel").addEventListener("click", () => { closeModal(); drawAnnotsForPage(pageIdx); }); box.querySelector("#cropOk").addEventListener("click", () => { const l = Number(box.querySelector("#cropL").value) || 0; const r = Number(box.querySelector("#cropR").value) || 0; const t = Number(box.querySelector("#cropT").value) || 0; const b = Number(box.querySelector("#cropB").value) || 0; const scope = box.querySelector('input[name=cropScope]:checked').value; pushUndo(); for (const entry of state.pagePlan) { const isThis = !entry.source && entry.srcIndex === pageIdx; if (scope === "all" || isThis) { const dims = (!entry.source && state.pages[entry.srcIndex]) ? state.pages[entry.srcIndex] : p; entry.crop = { left: l, bottom: b, right: dims.widthPt - r, top: dims.heightPt - t }; } } closeModal(); setTool(null); drawAnnotsForPage(pageIdx); $("status").textContent = "Đã đặt vùng crop — vào Tổ chức trang > Lưu để ghi ra file thật."; }); } // ---------- Phase 4: Sửa nội dung (Edit) ---------- // Mô hình "materialize tức thì": mỗi thao tác (sửa text / xoá / thêm / di // chuyển) được áp NGAY vào 1 file tạm mới (editBase) qua edit_apply, rồi đọc // lại object + render ảnh từ file đó. Nhờ vậy: index object luôn khớp ảnh đang // hiện (WYSIWYG thật), không phải tự suy đoán; undo = quay lại file tạm trước. const EDIT_STAGE_W = 820; // px bề rộng ảnh trang khi sửa function enterEditMode(pageIndex) { if (!state.path) { $("status").textContent = "Hãy mở file trước khi sửa nội dung"; return; } if (state.organizeMode) exitOrganizeMode(); state.editMode = true; state.editPage = Number.isInteger(pageIndex) ? pageIndex : state.current; state.editBase = state.path; state.editUndo = []; state.editRedo = []; state.editTemps = []; state.editSel = null; state.editArm = null; state.editPendingImage = null; $("annobar").classList.add("hidden"); $("editBar").classList.remove("hidden"); $("viewport").classList.add("hidden"); $("editStage").classList.remove("hidden"); $("sidebar").classList.add("hidden"); $("editModeBtn").classList.add("active"); setTool(null); loadEditPage(); } // Dọn mọi file làm việc tạm của phiên sửa (backend chỉ xoá đúng ff_edit_*.pdf // trong thư mục temp nên gọi thoải mái). function cleanupEditTemps() { if (state.editTemps.length) { invoke("edit_cleanup", { paths: state.editTemps.slice() }).catch(() => {}); state.editTemps = []; } } function exitEditMode() { cleanupEditTemps(); state.editMode = false; $("annobar").classList.remove("hidden"); $("editBar").classList.add("hidden"); $("viewport").classList.remove("hidden"); $("editStage").classList.add("hidden"); $("sidebar").classList.remove("hidden"); $("editModeBtn").classList.remove("active"); $("editOverlay").innerHTML = ""; } function toggleEditMode() { if (state.editMode) exitEditMode(); else enterEditMode(); } // Đúp chuột vào CHỮ trên viewer thường (chuẩn Foxit Editor): tự vào chế độ // Sửa nội dung đúng trang + mở ngay ô sửa tại đoạn vừa đúp. Đúp vùng trống // hoặc đang cầm công cụ chú thích thì không làm gì. async function onViewerDblClick(e) { if (state.editMode || state.organizeMode || state.tool) return; // Chú thích có handler đúp riêng (freetext) hoặc là element tương tác — bỏ qua. if (e.target.closest(".a-editor, .a-hl, .a-box, .a-text, .a-note, .redact-mark")) return; const slot = e.target.closest(".page-slot"); if (!slot) return; const idx = Number(slot.dataset.index); const r = slot.getBoundingClientRect(); const p = cssToPdf(idx, e.clientX - r.left, e.clientY - r.top); let objs; try { objs = await invoke("edit_list_objects", { path: state.path, page: idx, password: null }); } catch (_) { return; // trang không đọc được object → giữ nguyên viewer } if (!pickTextAt(objs, p.x, p.y)) return; // đúp vùng trống: như Foxit, không làm gì // Bỏ selection từ đúp chuột (browser tự bôi 1 từ trong text layer). const sel = window.getSelection(); if (sel) sel.removeAllRanges(); state.editPendingPoint = { x: p.x, y: p.y }; enterEditMode(idx); } // Đọc lại object + render ảnh trang hiện tại từ editBase, dựng overlay box. async function loadEditPage() { const p = state.pages[state.editPage]; state.editScale = EDIT_STAGE_W / p.widthPt; try { const [url, objs] = await Promise.all([ invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }), invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }), ]); $("editImg").src = url; state.editObjects = objs; buildEditOverlay(); $("editHint").textContent = `Trang ${state.editPage + 1} · ${objs.length} đối tượng`; } catch (e) { $("editHint").textContent = "Lỗi nạp trang sửa: " + e; } $("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào updateEditUndoButtons(); // Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp. const pending = state.editPendingPoint; if (pending) { state.editPendingPoint = null; const o = pickTextAt(state.editObjects, pending.x, pending.y); if (o) { selectEditObject(o.index); // Chờ ảnh trang decode xong để mọi phép đo theo ảnh (ô sửa, con trỏ) đúng. const img = $("editImg"); if (img.decode) { try { await img.decode(); } catch (_) {} } // Toạ độ chuột giả lập từ điểm PDF đã đúp → con trỏ đặt đúng chỗ đó. const ir = img.getBoundingClientRect(); startTextEdit(o, { clientX: ir.left + pending.x * state.editScale, clientY: ir.top + (p.heightPt - pending.y) * state.editScale, }); } } } // Text object dưới điểm (x,y) điểm PDF: ưu tiên box CHỨA điểm (nới 3pt, chọn // box nhỏ nhất khi chồng nhau), fallback box gần nhất trong 12pt. function pickTextAt(objs, x, y) { const texts = objs.filter((o) => o.kind === "text"); const pad = 3; const containing = texts.filter( (o) => x >= o.rect.left - pad && x <= o.rect.right + pad && y >= o.rect.bottom - pad && y <= o.rect.top + pad ); if (containing.length) { containing.sort( (a, b) => (a.rect.right - a.rect.left) * (a.rect.top - a.rect.bottom) - (b.rect.right - b.rect.left) * (b.rect.top - b.rect.bottom) ); return containing[0]; } let best = null; let bestD = 12; for (const o of texts) { const dx = Math.max(o.rect.left - x, 0, x - o.rect.right); const dy = Math.max(o.rect.bottom - y, 0, y - o.rect.top); const d = Math.hypot(dx, dy); if (d < bestD) { bestD = d; best = o; } } return best; } function editBoxStyle(rect) { const s = state.editScale; const p = state.pages[state.editPage]; return { left: rect.left * s + "px", top: (p.heightPt - rect.top) * s + "px", width: Math.max(2, (rect.right - rect.left) * s) + "px", height: Math.max(2, (rect.top - rect.bottom) * s) + "px", }; } function buildEditOverlay() { const ov = $("editOverlay"); ov.innerHTML = ""; ov.classList.toggle("armed", !!state.editArm); // TEXT: 1 khung mỗi DÒNG thị giác (như Foxit) — không vẽ ô chi chít từng // run khi PDF cắt dòng thành từng từ/ký tự. Chọn/kéo/xoá tác động CẢ DÒNG. const lines = clusterTextLines(state.editObjects); lines.forEach((line) => { const box = document.createElement("div"); box.className = "edit-box kind-text"; // Đại diện dòng: ưu tiên run chữ thật (không phải bullet) để đúp mở đúng đoạn. const rep = line.runs.find((r) => (r.text || "").trim() && !isMarkerRun(r, line.runs)) || line.runs.find((r) => (r.text || "").trim()) || line.runs[0]; box.dataset.index = String(rep.index); box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index)); Object.assign(box.style, editBoxStyle(line.rect)); box.title = composeLineText(line.runs); box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, rep, line.runs)); box.addEventListener("click", (e) => { if (state.editArm) return; e.stopPropagation(); selectEditObject(rep.index, line.runs.map((r) => r.index)); }); box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep, e); }); ov.appendChild(box); }); // Ảnh/form: khung theo từng object như cũ (có resize). state.editObjects.forEach((o) => { if (o.kind !== "image" && o.kind !== "form") return; // Form đã liệt kê con riêng (file Canva gói cả trang vào form): khung của // form phủ kín trang sẽ che hết con → bỏ. Ảnh/form TRONG form: chưa sửa // được (di chuyển/thay cần địa chỉ lồng) → cũng bỏ khung. if (o.expanded || o.nested) return; const box = document.createElement("div"); box.className = "edit-box kind-" + o.kind; box.dataset.index = String(o.index); Object.assign(box.style, editBoxStyle(o.rect)); box.title = o.kind; box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o, [o.index])); box.addEventListener("click", (e) => { if (state.editArm) return; e.stopPropagation(); selectEditObject(o.index, [o.index]); }); ov.appendChild(box); }); refreshEditSelection(); } // Cập nhật trạng thái chọn TẠI CHỖ (không dựng lại overlay) — quan trọng để // double-click không bị mất element giữa 2 lần click. function refreshEditSelection() { const ov = $("editOverlay"); ov.querySelectorAll(".edit-box").forEach((box) => { const isSel = Number(box.dataset.index) === state.editSel; box.classList.toggle("selected", isSel); const existing = box.querySelector(".ed-handle"); const o = state.editObjects.find((x) => x.index === Number(box.dataset.index)); const resizable = o && (o.kind === "image" || o.kind === "form"); if (isSel && resizable && !existing) { const h = document.createElement("div"); h.className = "ed-handle"; h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o)); box.appendChild(h); } else if ((!isSel || !resizable) && existing) { existing.remove(); } }); } // Chọn 1 khung. `runIndices`: mọi run thuộc khung (dòng text = nhiều run) — // thao tác xoá/kéo/đổi thuộc tính áp cho TẤT CẢ. function selectEditObject(index, runIndices) { state.editSel = index != null && index >= 0 ? index : null; state.editSelRuns = state.editSel != null ? (runIndices || [state.editSel]) : []; const o = state.editObjects.find((x) => x.index === state.editSel); const isText = o && o.kind === "text"; const isImage = o && o.kind === "image"; $("edDelete").disabled = !o; $("edReplaceImage").disabled = !isImage; $("edFontSize").disabled = !isText; $("edColorBtn").disabled = !isText; $("edFontFamily").disabled = !isText; $("edBold").disabled = !isText; $("edItalic").disabled = !isText; if (isText) { $("edFontSize").value = Math.round(o.fontSize || 12); if (o.color) { state.editColor = o.color.slice(0, 3); $("edSw").style.background = rgbCss(state.editColor); } // Ô font: mặc định "(giữ nguyên: )" — chỉ đổi khi người dùng chọn khác. $("edFontFamily").options[0].textContent = o.fontFamily ? `(giữ nguyên: ${o.fontFamily})` : "(giữ nguyên)"; $("edFontFamily").value = ""; $("edBold").classList.toggle("on", !!o.fontBold); $("edItalic").classList.toggle("on", !!o.fontItalic); const emb = o.fontEmbedded == null ? "" : o.fontEmbedded ? " · font nhúng" : " · font hệ thống"; $("editHint").textContent = `${o.fontFamily || o.fontName || "?"} · ${Math.round(o.fontSize || 0)}pt${emb}`; } else { $("edFontFamily").options[0].textContent = "(giữ nguyên)"; $("edBold").classList.remove("on"); $("edItalic").classList.remove("on"); } refreshEditSelection(); } function updateEditUndoButtons() { // Dùng chung 2 nút Hoàn tác/Làm lại toàn cục khi đang ở chế độ sửa. if (!state.editMode) return; $("undoBtn").disabled = state.editUndo.length === 0; $("redoBtn").disabled = state.editRedo.length === 0; } // Áp 1 nhóm op (1 thao tác người dùng): lưu editBase cũ vào stack undo, // materialize ra file tạm mới, ghi nhận file tạm để dọn khi thoát. async function stageEditOps(ops) { try { const out = await invoke("edit_apply_to_temp", { input: state.editBase, page: state.editPage, ops, password: null, }); state.editTemps.push(out); state.editUndo.push(state.editBase); state.editRedo = []; state.editBase = out; state.editSel = null; selectEditObject(-1); await loadEditPage(); } catch (e) { $("editHint").textContent = "Lỗi: " + e; } } function stageEditOp(op) { return stageEditOps([op]); } function editUndo() { if (!state.editUndo.length) return; state.editRedo.push(state.editBase); state.editBase = state.editUndo.pop(); state.editSel = null; loadEditPage(); } function editRedo() { if (!state.editRedo.length) return; state.editUndo.push(state.editBase); state.editBase = state.editRedo.pop(); state.editSel = null; loadEditPage(); } // CSS font-family xấp xỉ cho family PDF (để ô sửa hiển thị đúng dáng chữ). // Tên family từ engine là dạng PostScript CamelCase KHÔNG dấu cách // ("TimesNewRoman", "SegoeUI", "OpenSans") — không khớp tên font cài trên máy // nên CSS rơi về serif/sans-serif mặc định (ô sửa sai font). Map alias phổ biến // + tự tách CamelCase thành tên có dấu cách. const FONT_CSS_ALIASES = { helvetica: "Arial", helveticaneue: "Arial", arial: "Arial", arialmt: "Arial", times: "Times New Roman", timesroman: "Times New Roman", timesnewroman: "Times New Roman", timesnewromanps: "Times New Roman", courier: "Courier New", couriernew: "Courier New", couriernewps: "Courier New", segoeui: "Segoe UI", }; function cssFontStack(family) { if (!family) return "sans-serif"; const k = family.toLowerCase().replace(/[^a-z0-9]/g, ""); const generic = /times|georgia|garamond|palatino|cambria|book|serif/.test(k) ? "serif" : /courier|consolas|mono/.test(k) ? "monospace" : "sans-serif"; const parts = [`"${family}"`]; const spaced = family.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); if (spaced !== family) parts.push(`"${spaced}"`); const alias = FONT_CSS_ALIASES[k]; if (alias && alias !== family && alias !== spaced) parts.push(`"${alias}"`); parts.push(generic); return parts.join(", "); } // Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ. function composeLineText(runs) { let s = ""; for (let i = 0; i < runs.length; i++) { const t = runs[i].text || ""; if (i > 0) { const gap = runs[i].rect.left - runs[i - 1].rect.right; const em = (runs[i].fontSize || 12) * 0.28; if (gap > em && !s.endsWith(" ") && !t.startsWith(" ")) s += " "; } s += t; } return s; } // Gom TOÀN BỘ text run của trang thành các DÒNG thị giác. PDF từ Word hay cắt // 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, bbox chữ có dấu (Ạ, Ợ) // tụt thấp hơn hẳn — nên cluster theo GIAO DỌC (≥50% chiều cao nhỏ hơn) thay // vì so baseline, rồi tách cột theo khoảng hở ngang lớn. Trả mảng dòng // {runs (trái→phải, gồm cả run rỗng trong tầm), rect (union), fs (cỡ lớn nhất)}. function clusterTextLines(objs) { const texts = objs.filter((o) => o.kind === "text"); const real = texts.filter((o) => o.rect.top - o.rect.bottom > 0.5 && o.rect.right - o.rect.left > 0.1); const degenerate = texts.filter((o) => !real.includes(o)); // run rỗng/điểm // (1) Cụm theo giao dọc. const bands = []; for (const r of real.slice().sort((a, b) => b.rect.bottom - a.rect.bottom)) { const h = r.rect.top - r.rect.bottom; const band = bands.find((bd) => { const ov = Math.min(bd.top, r.rect.top) - Math.max(bd.bottom, r.rect.bottom); return ov > 0.5 * Math.min(bd.top - bd.bottom, h); }); if (band) { band.runs.push(r); band.top = Math.max(band.top, r.rect.top); band.bottom = Math.min(band.bottom, r.rect.bottom); } else { bands.push({ top: r.rect.top, bottom: r.rect.bottom, runs: [r] }); } } // (2) Trong mỗi cụm dọc: xếp trái→phải, tách dòng/cột khi hở quá lớn. const lines = []; for (const band of bands) { band.runs.sort((a, b) => a.rect.left - b.rect.left); let cur = null; for (const r of band.runs) { const fs = r.fontSize || 12; const gapMax = Math.max(fs * 1.7, 10); if (cur && r.rect.left - cur.rect.right <= gapMax) { cur.runs.push(r); cur.rect.right = Math.max(cur.rect.right, r.rect.right); cur.rect.top = Math.max(cur.rect.top, r.rect.top); cur.rect.bottom = Math.min(cur.rect.bottom, r.rect.bottom); cur.fs = Math.max(cur.fs, fs); } else { cur = { runs: [r], fs, rect: { left: r.rect.left, right: r.rect.right, top: r.rect.top, bottom: r.rect.bottom } }; lines.push(cur); } } } // (3) Gắn run rỗng vào dòng chứa nó (để khi sửa xoá sạch, không sót). for (const d of degenerate) { const line = lines.find( (l) => d.rect.bottom >= l.rect.bottom - 3 && d.rect.bottom <= l.rect.top + 3 && d.rect.left >= l.rect.left - 6 && d.rect.left <= l.rect.right + 6 ); if (line) line.runs.push(d); } for (const l of lines) l.runs.sort((a, b) => a.rect.left - b.rect.left); return lines; } // Run "marker" đầu dòng (bullet/dấu gạch danh sách): Word/LibreOffice xuất // bullet bằng font Symbol/Wingdings với mã PUA (U+F0B7…) — DOM không render // được (hiện ô vuông □) và Foxit cũng KHÔNG cho sửa bullet như chữ thường. // Nhận diện để LOẠI khỏi ô sửa + commit (bullet giữ nguyên trên trang). const BULLET_CHARS = new Set([..."•◦▪▫‣·∙●○■□◆♦►▶➢➤§–—"]); function isMarkerRun(run, lineRuns) { const t = (run.text || "").trim(); if (!t || [...t].length > 2) return false; // Phải là run có chữ TRÁI NHẤT của dòng. const firstReal = lineRuns.find((r) => (r.text || "").trim()); if (firstReal !== run) return false; const chars = [...t]; const puaOrBullet = chars.every((ch) => { const c = ch.codePointAt(0); return (c >= 0xe000 && c <= 0xf8ff) || BULLET_CHARS.has(ch); }); if (puaOrBullet) return true; if (/symbol|wingding|dingbat|marlett|webding/i.test(run.fontFamily || "")) return true; // Bullet ASCII mơ hồ ("-", "*", "o"): chỉ nhận khi có KHOẢNG HỞ rõ với chữ sau. if (chars.length === 1 && "-*o".includes(t)) { const next = lineRuns.find((r) => r !== run && (r.text || "").trim()); if (next) { const gap = next.rect.left - run.rect.right; return gap > (run.fontSize || 12) * 0.4; } } return false; } // Bỏ marker khỏi 1 dòng cluster: runs còn lại + rect/fs tính lại từ phần chữ // thật (để ô sửa, thụt lề, so-format không dính bbox của bullet). function stripLineMarkers(line) { const marker = line.runs.find((r) => isMarkerRun(r, line.runs)); if (!marker) return line; const runs = line.runs.filter((r) => r !== marker); const real = runs.filter((r) => (r.text || "").trim() && r.rect.top - r.rect.bottom > 0.5); if (!real.length) return { runs, fs: line.fs, rect: { ...line.rect } }; return { runs, fs: real.reduce((m, r) => Math.max(m, r.fontSize || 12), 0) || line.fs, rect: { left: Math.min(...real.map((r) => r.rect.left)), right: Math.max(...real.map((r) => r.rect.right)), top: Math.max(...real.map((r) => r.rect.top)), bottom: Math.min(...real.map((r) => r.rect.bottom)), }, }; } // Format đại diện của 1 dòng (từ run có chữ đầu tiên) — để so "cùng đoạn". function lineFormat(l) { const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0]; if (!rep) return null; return { fam: (rep.fontFamily || "").toLowerCase(), bold: !!rep.fontBold, italic: !!rep.fontItalic, color: (rep.color || [0, 0, 0]).slice(0, 3).join(","), }; } // Gom ĐOẠN VĂN quanh run `o` — CHUẨN FOXIT: mỗi đoạn là 1 khối riêng, chỉ gom // các dòng kề nhau CÙNG FORMAT (font + đậm/nghiêng + màu, cỡ chênh ≤1.25× — // tiêu đề 22.5/20pt vẫn 1 đoạn nhưng tiêu đề ≠ thân bài ≠ dòng code), khoảng // hở ≤1.3× chiều cao dòng, giao ngang ≥30%, nhịp dòng đều ±35%. Bullet đầu // dòng bị loại khỏi khối (giữ nguyên trên trang). Trả mảng dòng trên→dưới. function paragraphLines(o) { const raw = clusterTextLines(state.editObjects); const rawMine = raw.find((l) => l.runs.some((r) => r.index === o.index)); if (!rawMine) return [ { runs: [o], fs: o.fontSize || 12, rect: { ...o.rect } } ]; const pairs = raw .map((l) => ({ raw: l, s: stripLineMarkers(l) })) .filter((p) => p.s.runs.some((r) => (r.text || "").trim())); const minePair = pairs.find((p) => p.raw === rawMine); if (!minePair) return [rawMine]; // dòng chỉ có mỗi bullet (hiếm) → sửa mình nó const all = pairs.map((p) => p.s); const mine = minePair.s; const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom); const overlaps = (a, b) => Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left) > 0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left); const sizeOk = (a, b) => { const r = a.fs / b.fs; return r <= 1.25 && r >= 1 / 1.25; }; const myFmt = lineFormat(mine); const fmtOk = (l) => { const f = lineFormat(l); return ( f && myFmt && f.fam === myFmt.fam && f.bold === myFmt.bold && f.italic === myFmt.italic && f.color === myFmt.color ); }; const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới const iMine = sorted.indexOf(mine); const block = [mine]; let advance = null; // Mở rộng XUỐNG rồi LÊN, khoá nhịp khoảng cách dòng ±35% sau lần đầu. const tryDir = (step) => { let edge = mine; for (let i = iMine + step; i >= 0 && i < sorted.length; i += step) { const cand = sorted[i]; const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top; const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand)); if (gap < -3 || gap > maxGap) break; if (!overlaps(edge, cand) || !sizeOk(edge, cand) || !fmtOk(cand)) break; const adv = Math.abs(edge.rect.bottom - cand.rect.bottom); if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break; if (advance == null) advance = adv; if (step > 0) block.push(cand); else block.unshift(cand); edge = cand; } }; tryDir(+1); tryDir(-1); return block.map((l) => l); // mỗi phần tử: {runs, fs, rect} } // Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một // đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow. // `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp. function startTextEdit(o, ev) { startBlockTextEdit(o, paragraphLines(o), ev); } // Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối, // MỖI DÒNG GỐC = 1 div giữ ĐÚNG font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ (tiêu đề // 22.5/20pt hiện đúng 2 cỡ), khối căn giữa hiện căn giữa — chữ trong ô nằm // TRÙNG vị trí chữ gốc, không xê dịch. Enter = xuống dòng; click trong ô = // di chuyển con trỏ; commit → engine reflow (xoá sạch run cũ, giữ font gốc, // giữ cỡ từng dòng, giữ căn giữa). function startBlockTextEdit(o, lines, ev) { const ov = $("editOverlay"); const existing = ov.querySelector(".edit-inline"); if (existing) existing.remove(); const s = state.editScale; const p = state.pages[state.editPage]; const allRuns = lines.flatMap((l) => l.runs); const union = { left: Math.min(...lines.map((l) => l.rect.left)), right: Math.max(...lines.map((l) => l.rect.right)), bottom: Math.min(...lines.map((l) => l.rect.bottom)), top: Math.max(...lines.map((l) => l.rect.top)), }; const bw = union.right - union.left; const bc = (union.left + union.right) / 2; // Căn giữa? — cùng tiêu chí với engine: tâm mọi dòng ≈ tâm khối và có ít // nhất 1 dòng thụt vào so với lề trái. const tol = Math.max(2, bw * 0.02); const centered = lines.every((l) => Math.abs((l.rect.left + l.rect.right) / 2 - bc) <= tol) && lines.some((l) => l.rect.left - union.left > tol); const original = lines.map((l) => composeLineText(l.runs)).join("\n"); const ce = document.createElement("div"); ce.className = "edit-inline edit-inline-wys"; ce.contentEditable = "plaintext-only"; ce.spellcheck = false; // Khung: khối căn giữa nở đều 2 phía (tâm giữ nguyên → chữ không xê dịch); // khối thường giữ mép trái, nở sang phải — khớp giới hạn nở 35% của engine. let leftPt; let widthPt; if (centered) { const grow = Math.min(bw * 0.175, bc - 8 - bw / 2, p.widthPt - 8 - bc - bw / 2); const g = Math.max(0, grow); leftPt = union.left - g; widthPt = bw + 2 * g; } else { leftPt = union.left; widthPt = Math.min(bw * 1.35, p.widthPt - union.left - 8); } // Advance TỪNG DÒNG = khoảng cách baseline dòng này → dòng dưới (xấp xỉ // bằng hiệu mép DƯỚI 2 bbox — bbox PDF ôm sát glyph nên bottom bám baseline). // KHÔNG dùng khoảng hở giữa 2 bbox (bottom trên − top dưới ≈ 0/âm) — chính // là bug làm các dòng dồn cục 8px. const perLineAdvances = []; for (let i = 0; i < lines.length; i++) { let advPt; if (i < lines.length - 1) { advPt = lines[i].rect.bottom - lines[i + 1].rect.bottom; } if (!(advPt > 0)) advPt = (lines[i].fs || 12) * 1.25; perLineAdvances.push(Math.max(8, advPt * s)); } const totalHeightPx = perLineAdvances.reduce((a, b) => a + b, 0); // Bù lệch dọc: line-box của trình duyệt căn giữa glyph trong line-height, // còn bbox PDF ôm sát glyph → đẩy khung lên (line-height − cỡ chữ)/2 cho trùng. const fs0 = (lines[0].fs || o.fontSize || 12) * s; const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (perLineAdvances[0] - fs0) / 2)); Object.assign(ce.style, { left: leftPt * s + "px", top: topPx + "px", width: widthPt * s + "px", minHeight: totalHeightPx + 4 + "px", textAlign: centered ? "center" : "left", }); for (let i = 0; i < lines.length; i++) { const l = lines[i]; const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0] || o; const div = document.createElement("div"); div.className = "edit-line"; const t = composeLineText(l.runs); if (t) div.textContent = t; else div.innerHTML = "
"; const divFs = Math.max(6, (l.fs || 12) * s); div.style.fontSize = divFs + "px"; div.style.lineHeight = perLineAdvances[i] + "px"; div.style.fontFamily = cssFontStack(rep.fontFamily || o.fontFamily); div.style.fontWeight = (rep.fontBold != null ? rep.fontBold : o.fontBold) ? "bold" : "normal"; div.style.fontStyle = (rep.fontItalic != null ? rep.fontItalic : o.fontItalic) ? "italic" : "normal"; if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color); // Giữ thụt lề từng dòng so với mép khối (dòng đầu đoạn thụt vào…). if (!centered) { const indent = (l.rect.left - leftPt) * s; if (indent > 0.5) div.style.textIndent = indent + "px"; } ce.appendChild(div); } // Ẩn khung dòng overlay của các run đang sửa — nền ô trong suốt nên khung // này lộ qua gây nhiễu; khôi phục khi đóng ô. ov.querySelectorAll(".edit-box.kind-text").forEach((b) => { const rIdxs = JSON.parse(b.dataset.runs || "[]"); if (rIdxs.some((i) => allRuns.some((r) => r.index === i))) b.style.display = "none"; }); ov.appendChild(ce); // WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường. if (!ce.isContentEditable) ce.contentEditable = "true"; // ĐO chữ vừa render rồi HIỆU CHỈNH cỡ/vị trí từng dòng cho TRÙNG bbox gốc — // phải chạy TRƯỚC focus/đặt con trỏ để caretRangeFromPoint trúng layout cuối. fitEditLinesToPdf(ce, lines, s, p); ce.focus(); // NỀN TRONG SUỐT chuẩn Foxit: render nền trang ĐÃ ẨN các run đang sửa (áp // Delete ra file tạm chỉ để lấy ảnh — commit vẫn tính trên editBase gốc). // Trong lúc chờ render giữ nền trắng che chữ cũ, xong mới chuyển trong suốt // → thấy nguyên dải màu nền/bullet/kẻ bảng dưới ô sửa. const img = $("editImg"); const prevSrc = img.src; let bgSwapped = false; (async () => { try { const tmp = await invoke("edit_apply_to_temp", { input: state.editBase, page: state.editPage, ops: allRuns.map((r) => ({ op: "delete", index: r.index })), password: null, }); state.editTemps.push(tmp); const url = await invoke("render_page", { path: tmp, page: state.editPage, width: EDIT_STAGE_W }); if (ce.isConnected) { img.src = url; bgSwapped = true; ce.style.background = "transparent"; } } catch (_) { /* không render được thì giữ nền trắng như cũ */ } })(); // Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn. let placed = false; if (ev && document.caretRangeFromPoint) { const r = document.caretRangeFromPoint(ev.clientX, ev.clientY); if (r && ce.contains(r.startContainer)) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); placed = true; } } if (!placed) { const sel = window.getSelection(); const r = document.createRange(); r.setStart(ce, 0); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); } ce.scrollIntoView({ block: "center", behavior: "instant" }); $("editHint").textContent = "Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; // Đọc nội dung theo DÒNG từ contenteditable (div/br → \n). const readText = () => { let t = ce.innerText != null ? ce.innerText : ce.textContent; t = t.replace(/\u00a0/g, " ").replace(/\n+$/, ""); return t; }; let done = false; const commit = (save) => { if (done) return; done = true; const text = readText(); ce.remove(); ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; }); const changed = save && text.trim() && text !== original; // Huỷ/không đổi gì → trả lại ảnh trang gốc (đang hiện bản ẩn run sửa). if (!changed && bgSwapped) img.src = prevSrc; if (changed) { stageEditOp({ op: "reflowText", indices: allRuns.map((r) => r.index), text, }); } else { $("editHint").textContent = ""; } }; ce.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); commit(false); } else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); } e.stopPropagation(); }); // Dán luôn ở dạng chữ thuần (khi rơi về contenteditable thường). ce.addEventListener("paste", (e) => { if (ce.contentEditable === "plaintext-only") return; e.preventDefault(); const t = (e.clipboardData || window.clipboardData).getData("text/plain"); document.execCommand("insertText", false, t); }); // Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit). ce.addEventListener("mousedown", (e) => e.stopPropagation()); ce.addEventListener("click", (e) => e.stopPropagation()); ce.addEventListener("dblclick", (e) => e.stopPropagation()); ce.addEventListener("blur", () => commit(true)); } // ĐO chữ đã render trong ô sửa rồi HIỆU CHỈNH cho trùng khít chữ gốc — vòng // đo-bù nên đúng với MỌI loại file, không phụ thuộc engine trả cỡ chuẩn hay // công thức line-height của trình duyệt: // (1) CỠ CHỮ: chiều cao MỰC của đúng chuỗi đó (canvas actualBoundingBox) phải // bằng chiều cao bbox dòng trong PDF (cùng là ink-bbox, gồm cả dấu tiếng // Việt) — engine trả cỡ lệch (matrix scale lạ) hay font thay thế lệch // metric đều tự sửa; lệch <12% coi như khác biệt metric, giữ nguyên. // (2) VỊ TRÍ: quy cả 2 phía về MÉP MỰC TRÊN của dòng (DOM: đỉnh em-box của // Range + (fontAscent − inkAscent); PDF: rect.top) rồi bù margin-top / // text-indent từng dòng. Dòng đầu bù vào chính khung (giữ nền trắng che // kín chữ cũ), các dòng sau bù margin (đo tuần tự nên tự cộng dồn đúng). function fitEditLinesToPdf(ce, lines, s, page) { const img = $("editImg"); if (!img) return; const canvas = (fitEditLinesToPdf._c = fitEditLinesToPdf._c || document.createElement("canvas")); const ctx = canvas.getContext("2d"); const divs = Array.from(ce.children); const centered = ce.style.textAlign === "center"; const fontOf = (div, fsOverride) => { const fs = parseFloat(div.style.fontSize) || 12; const style = div.style.fontStyle === "italic" ? "italic " : ""; const weight = div.style.fontWeight === "bold" ? "bold " : ""; return { fs, css: `${style}${weight}${fsOverride || fs}px ${div.style.fontFamily || "sans-serif"}` }; }; // (1) Cỡ chữ theo chiều cao mực thật. Đo ở cỡ THAM CHIẾU lớn (100px) rồi // suy tuyến tính — đo trực tiếp ở cỡ nhỏ bị hinting làm tròn lệch tới ~5%. const REF = 100; for (let i = 0; i < divs.length && i < lines.length; i++) { const text = (divs[i].textContent || "").trim(); if (!text) continue; const f = fontOf(divs[i], REF); ctx.font = f.css; const m = ctx.measureText(text); const inkPerPx = ((m.actualBoundingBoxAscent || 0) + (m.actualBoundingBoxDescent || 0)) / REF; const targetPx = (lines[i].rect.top - lines[i].rect.bottom) * s; if (inkPerPx > 0.01 && targetPx > 1) { const wantFs = targetPx / inkPerPx; const ratio = wantFs / f.fs; if (ratio < 0.88 || ratio > 1.12) { divs[i].style.fontSize = f.fs * Math.max(0.3, Math.min(3, ratio)) + "px"; } } } // (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ). const imgR = img.getBoundingClientRect(); for (let i = 0; i < divs.length && i < lines.length; i++) { const r = firstTextRect(divs[i]); if (!r) continue; const text = (divs[i].textContent || "").trim(); const f = fontOf(divs[i]); ctx.font = f.css; const m = ctx.measureText(text); const fontAscent = m.fontBoundingBoxAscent || f.fs * 0.8; const inkAscent = m.actualBoundingBoxAscent || fontAscent; const curInkTop = r.top + (fontAscent - inkAscent); const dy = imgR.top + (page.heightPt - lines[i].rect.top) * s - curInkTop; if (Math.abs(dy) > 0.5) { if (i === 0) ce.style.top = parseFloat(ce.style.top) + dy + "px"; else divs[i].style.marginTop = (parseFloat(divs[i].style.marginTop) || 0) + dy + "px"; } // Ngang: khối căn trái bù qua ce.left (dòng đầu) / text-indent (dòng sau); // khối căn giữa bù mọi dòng qua text-indent (indent cộng thêm vào offset // căn giữa nên vẫn dịch được từng dòng mà không phá cơ chế center). const dx = imgR.left + lines[i].rect.left * s - r.left; if (Math.abs(dx) > 0.5) { if (!centered && i === 0) ce.style.left = parseFloat(ce.style.left) + dx + "px"; else divs[i].style.textIndent = (parseFloat(divs[i].style.textIndent) || 0) + dx + "px"; } } } // Bbox render thật của text trong div — đo bằng Range (chỉ tính chữ, không // tính line-box nên không dính line-height); null nếu dòng rỗng. function firstTextRect(div) { const tn = div.firstChild; if (!tn || tn.nodeType !== Node.TEXT_NODE) return null; const range = document.createRange(); range.selectNodeContents(tn); const rects = range.getClientRects(); return rects.length ? rects[0] : null; } // Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung // từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN. function applyTextPropToSelected(part) { const runs = state.editSelRuns .map((i) => state.editObjects.find((x) => x.index === i)) .filter((x) => x && x.kind === "text"); if (!runs.length) return; stageEditOps( runs.map((r) => ({ op: "setText", index: r.index, text: r.text || "", fontSize: null, color: null, fontFamily: null, bold: null, italic: null, ...part, })) ); } function deleteSelectedEditObject() { if (!state.editSelRuns.length) return; stageEditOps(state.editSelRuns.map((i) => ({ op: "delete", index: i }))); } // Click lên trang khi đang "armed" để đặt chữ/ảnh mới. function onEditStageClick(e) { // Click vào ô sửa đang mở (di chuyển con trỏ) — không được đụng overlay. if (e.target.closest && e.target.closest(".edit-inline")) return; if (!state.editArm) { selectEditObject(-1); state.editSel = null; buildEditOverlay(); return; } const img = $("editImg"); const r = img.getBoundingClientRect(); const cssX = e.clientX - r.left; const cssY = e.clientY - r.top; const p = state.pages[state.editPage]; const pdfX = cssX / state.editScale; const pdfY = p.heightPt - cssY / state.editScale; if (state.editArm === "text") { state.editArm = null; $("edAddText").classList.remove("armed"); promptAddText(pdfX, pdfY); } else if (state.editArm === "image") { const path = state.editPendingImage; state.editArm = null; state.editPendingImage = null; $("edAddImage").classList.remove("armed"); if (path) { // Khung mặc định 150×112pt (4:3) — người dùng kéo handle để chỉnh lại. stageEditOp({ op: "addImage", x: pdfX, y: pdfY - 112, widthPt: 150, heightPt: 112, imagePath: path }); } } $("editOverlay").classList.remove("armed"); } function promptAddText(pdfX, pdfY) { const ov = $("editOverlay"); const inp = document.createElement("input"); inp.className = "edit-inline"; inp.placeholder = "Nhập chữ…"; const s = state.editScale; const p = state.pages[state.editPage]; const family = $("edFontFamily").value || null; // null = font mặc định const size = Number($("edFontSize").value) || 16; inp.style.left = pdfX * s + "px"; inp.style.top = (p.heightPt - pdfY) * s + "px"; inp.style.minWidth = "120px"; inp.style.fontSize = Math.max(10, size * s) + "px"; inp.style.fontFamily = cssFontStack(family); inp.style.color = rgbCss(state.editColor); ov.appendChild(inp); inp.focus(); let done = false; const commit = (save) => { if (done) return; done = true; const text = inp.value; inp.remove(); if (save && text.trim()) { stageEditOp({ op: "addText", x: pdfX, y: pdfY, text, fontSize: size, color: [state.editColor[0], state.editColor[1], state.editColor[2], 255], fontFamily: family, bold: null, italic: null, }); } }; inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(true); } else if (e.key === "Escape") { e.preventDefault(); commit(false); } e.stopPropagation(); }); inp.addEventListener("blur", () => commit(true)); } // Kéo để di chuyển khung đang chọn (dòng text = kéo CẢ DÒNG) — box đi theo // con trỏ NGAY (live feedback như Foxit), thả chuột mới commit vào engine. function onEditBoxMouseDown(e, o, runs) { if (state.editArm) return; if (e.target.classList.contains("ed-handle")) return; const runObjs = runs && runs.length ? runs : [o]; const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index)); selectEditObject(o.index, runIndices); // Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ // kéo di chuyển/resize — chọn thôi, không bắt đầu drag. if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return; const box = e.currentTarget; const startX = e.clientX, startY = e.clientY; const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); let moved = false; const onMove = (ev) => { const dx = ev.clientX - startX, dy = ev.clientY - startY; if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) { moved = true; box.classList.add("dragging"); } if (moved) { box.style.left = left0 + dx + "px"; box.style.top = top0 + dy + "px"; } }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); box.classList.remove("dragging"); if (!moved) return; const dx = (ev.clientX - startX) / state.editScale; const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm stageEditOps(runIndices.map((i) => ({ op: "transform", index: i, dx, dy, sx: 1, sy: 1 }))); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); } // Kéo handle góc dưới-phải để resize — khung đổi kích thước live, thả commit. function onEditResizeMouseDown(e, o) { e.stopPropagation(); e.preventDefault(); const box = e.target.parentElement; const startX = e.clientX, startY = e.clientY; const w0 = (o.rect.right - o.rect.left), h0 = (o.rect.top - o.rect.bottom); const cssW0 = parseFloat(box.style.width), cssH0 = parseFloat(box.style.height); const onMove = (ev) => { box.classList.add("dragging"); box.style.width = Math.max(4, cssW0 + (ev.clientX - startX)) + "px"; box.style.height = Math.max(4, cssH0 + (ev.clientY - startY)) + "px"; }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); box.classList.remove("dragging"); const dwPt = (ev.clientX - startX) / state.editScale; const dhPt = (ev.clientY - startY) / state.editScale; // kéo xuống = cao hơn (y giảm ở đáy) const sx = Math.max(0.1, (w0 + dwPt) / Math.max(1, w0)); const sy = Math.max(0.1, (h0 + dhPt) / Math.max(1, h0)); if (Math.abs(sx - 1) > 0.01 || Math.abs(sy - 1) > 0.01) { // Engine scale quanh góc DƯỚI-trái; bù dy để neo góc TRÊN-trái đứng yên // (khớp handle góc dưới-phải + preview khi kéo, như Foxit). stageEditOp({ op: "transform", index: o.index, dx: 0, dy: -(h0 * (sy - 1)), sx, sy }); } else { loadEditPage(); // trả khung về đúng kích thước cũ } }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); } async function armAddImage() { const path = await invoke("pick_image"); if (!path) return; state.editPendingImage = path; state.editArm = "image"; $("edAddImage").classList.add("armed"); $("editOverlay").classList.add("armed"); $("editHint").textContent = "Bấm lên trang để đặt ảnh"; } async function replaceSelectedImage() { if (state.editSel == null) return; const o = state.editObjects.find((x) => x.index === state.editSel); if (!o || o.kind !== "image") return; const path = await invoke("pick_image"); if (!path) return; stageEditOp({ op: "replaceImage", index: o.index, imagePath: path }); } async function saveEdits() { const out = await invoke("pick_save_pdf"); if (!out) return; try { // editBase đã gồm mọi thay đổi của trang đang sửa; ghi ra output (ops rỗng = sao chép/lưu lại). await invoke("edit_apply", { input: state.editBase, page: state.editPage, ops: [], output: out, password: null }); $("status").textContent = `Đã lưu nội dung đã sửa → ${shortName(out)}`; exitEditMode(); loadDocument(out); } catch (e) { $("editHint").textContent = "Lỗi lưu: " + e; } } // ---------- Phase 7: OCR & Chuyển đổi ---------- async function toggleConvMode() { state.convMode = !state.convMode; $("convBar").classList.toggle("hidden", !state.convMode); $("convModeBtn").classList.toggle("active", state.convMode); if (state.convMode) { try { const st = await invoke("convert_tools_status"); const miss = []; if (!st.tesseract) miss.push("Tesseract (OCR)"); if (!st.soffice) miss.push("LibreOffice (Office↔PDF chất lượng cao)"); $("convHint").textContent = miss.length ? `Chưa cài: ${miss.join(", ")} — các nút liên quan sẽ báo lỗi kèm hướng dẫn` : ""; $("cvOcr").disabled = !st.tesseract; $("cvOffice").disabled = !st.soffice; } catch (_) { /* trạng thái chỉ để gợi ý */ } } else { $("convHint").textContent = ""; } } async function runOcrAction() { const lang = $("cvLang").value || "vie+eng"; const out = await invoke("pick_save_pdf"); if (!out) return; $("convHint").textContent = "Đang OCR… (tài liệu dài có thể mất vài phút)"; try { const n = await invoke("ocr_run", { input: state.path, lang, output: out }); $("status").textContent = `OCR xong: ${n} từ, đã thêm lớp text ẩn → ${shortName(out)}`; $("convHint").textContent = ""; loadDocument(out); } catch (e) { $("convHint").textContent = "Lỗi OCR: " + e; } } async function exportPngAction() { const dir = await invoke("pick_dir"); if (!dir) return; try { const files = await invoke("convert_images", { input: state.path, outDir: dir, dpi: 150 }); $("status").textContent = `Đã xuất ${files.length} ảnh PNG (150 DPI) vào ${dir}`; } catch (e) { $("convHint").textContent = "Lỗi xuất PNG: " + e; } } async function exportTxtAction() { const base = shortName(state.path).replace(/\.pdf$/i, ""); const out = await invoke("pick_save_as", { ext: "txt", name: base + ".txt" }); if (!out) return; try { await invoke("convert_txt", { input: state.path, output: out }); $("status").textContent = `Đã xuất text → ${shortName(out)}`; } catch (e) { $("convHint").textContent = "Lỗi xuất TXT: " + e; } } async function exportDocxAction() { const base = shortName(state.path).replace(/\.pdf$/i, ""); const out = await invoke("pick_save_as", { ext: "docx", name: base + ".docx" }); if (!out) return; $("convHint").textContent = "Đang chuyển sang Word…"; try { const engine = await invoke("convert_docx", { input: state.path, output: out }); const note = engine === "libreoffice" ? "LibreOffice (giữ layout tốt)" : "bộ chuyển cơ bản (text + bố cục)"; $("status").textContent = `Đã xuất Word (${note}) → ${shortName(out)}`; $("convHint").textContent = ""; } catch (e) { $("convHint").textContent = "Lỗi xuất Word: " + e; } } async function officeToPdfAction() { const src = await invoke("pick_office_file"); if (!src) return; const dir = await invoke("pick_dir"); if (!dir) return; $("convHint").textContent = "Đang chuyển Office → PDF…"; try { const out = await invoke("office_convert", { input: src, outDir: dir }); $("status").textContent = `Đã chuyển → ${shortName(out)}`; $("convHint").textContent = ""; loadDocument(out); } catch (e) { $("convHint").textContent = "Lỗi Office→PDF (máy cần LibreOffice): " + e; } } // ---------- Phase 6: Form (AcroForm) ---------- function toggleFormMode() { state.formMode = !state.formMode; $("formBar").classList.toggle("hidden", !state.formMode); $("formModeBtn").classList.toggle("active", state.formMode); $("formHint").textContent = ""; if (state.formMode) refreshFormCount(); } async function refreshFormCount() { try { const fields = await invoke("form_list", { path: state.path }); state.formFields = fields; $("fmCount").textContent = fields.length; $("formHint").textContent = fields.length ? `${fields.length} field trong tài liệu` : "Tài liệu chưa có field form — dùng “Thêm field”."; } catch (e) { $("formHint").textContent = "Lỗi đọc form: " + e; } } // Modal liệt kê field + input để điền, nút Lưu. async function openFillForm() { let fields; try { fields = await invoke("form_list", { path: state.path }); } catch (e) { $("formHint").textContent = "Lỗi đọc form: " + e; return; } if (!fields.length) { openModal("Điền form", `

Tài liệu chưa có field form nào. Hãy dùng “➕ Thêm field”.

`) .querySelector("#ffClose").addEventListener("click", closeModal); return; } const rows = fields .map((f, i) => { const label = `${escapeHtml(f.name)} (${f.kind}${f.pageIndex != null ? `, trang ${f.pageIndex + 1}` : ""})`; let input; if (f.kind === "checkbox" || f.kind === "radio") { const on = f.value && f.value !== "Off"; input = ``; } else if ((f.kind === "combo" || f.kind === "list") && f.options.length) { const opts = f.options .map((o) => ``) .join(""); input = ``; } else { input = ``; } return `${label}${input}`; }) .join(""); const box = openModal("Điền form", ` ${rows}
`); box.querySelector("#ffCancel").addEventListener("click", closeModal); box.querySelector("#ffOk").addEventListener("click", async () => { const values = fields.map((f, i) => { const el = box.querySelector(`#ffv${i}`); let value; if (f.kind === "checkbox" || f.kind === "radio") value = el.checked ? "on" : "off"; else value = el.value; return { name: f.name, value }; }); const out = await invoke("pick_save_pdf"); if (!out) return; try { const n = await invoke("form_fill", { input: state.path, values, output: out }); closeModal(); $("status").textContent = `Đã điền ${n} field → ${shortName(out)}`; loadDocument(out); } catch (e) { box.querySelector("#ffErr").textContent = "Lỗi: " + e; } }); } function openCreateFieldDialog() { const box = openModal("Thêm field mới", `
`); const kindSel = box.querySelector("#nfKind"); const toggleOpts = () => { const isCombo = kindSel.value === "combo"; box.querySelector("#nfOptLabel").classList.toggle("hidden", !isCombo); box.querySelector("#nfOpts").classList.toggle("hidden", !isCombo); }; kindSel.addEventListener("change", toggleOpts); box.querySelector("#nfCancel").addEventListener("click", closeModal); box.querySelector("#nfOk").addEventListener("click", async () => { const name = box.querySelector("#nfName").value.trim(); const err = box.querySelector("#nfErr"); if (!name) { err.textContent = "Cần nhập tên field."; return; } const kind = kindSel.value; const page = Math.max(1, Number(box.querySelector("#nfPage").value) || 1) - 1; const m = box.querySelector("#nfSize").value.match(/^\s*(\d+)\s*[x×]\s*(\d+)\s*$/); if (!m) { err.textContent = "Cỡ phải dạng rộng×cao, vd 200x20."; return; } const w = Number(m[1]), h = Number(m[2]); const x = Number(box.querySelector("#nfX").value) || 0; const y = Number(box.querySelector("#nfY").value) || 0; const options = kind === "combo" ? box.querySelector("#nfOpts").value.split(",").map((s) => s.trim()).filter(Boolean) : []; const field = { name, kind, pageIndex: page, rect: [x, y, x + w, y + h], value: "", options }; const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("form_create", { input: state.path, fields: [field], output: out }); closeModal(); $("status").textContent = `Đã tạo field “${name}” → ${shortName(out)}`; loadDocument(out); } catch (e) { err.textContent = "Lỗi: " + e; } }); } async function flattenFormAction() { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("form_flatten", { input: state.path, output: out, password: null }); $("status").textContent = `Đã flatten form → ${shortName(out)}`; loadDocument(out); } catch (e) { $("formHint").textContent = "Lỗi flatten: " + e; } } async function exportFormData(kind) { const out = await invoke("pick_save_data"); if (!out) return; // Đảm bảo đúng đuôi theo lựa chọn. let target = out; if (kind === "csv" && !/\.csv$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".csv"; if (kind === "fdf" && !/\.fdf$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".fdf"; try { await invoke("form_export", { input: state.path, output: target }); $("formHint").textContent = `Đã xuất dữ liệu form → ${shortName(target)}`; } catch (e) { $("formHint").textContent = "Lỗi xuất: " + e; } } async function importFormFdf() { const fdf = await invoke("pick_fdf"); if (!fdf) return; const out = await invoke("pick_save_pdf"); if (!out) return; try { const n = await invoke("form_import_fdf", { input: state.path, fdf, output: out }); $("status").textContent = `Đã nhập ${n} field từ FDF → ${shortName(out)}`; loadDocument(out); } catch (e) { $("formHint").textContent = "Lỗi nhập FDF: " + e; } } // ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ---------- function toggleSecMode() { state.secMode = !state.secMode; $("secBar").classList.toggle("hidden", !state.secMode); $("secModeBtn").classList.toggle("active", state.secMode); if (!state.secMode && state.tool === "redact") setTool(null); $("secHint").textContent = ""; } function updateRedactButtons() { const n = state.redactMarks.length; $("redactCount").textContent = n; $("secRedactApply").disabled = n === 0; $("secRedactClear").disabled = n === 0; } function clearRedactMarks() { const pages = [...new Set(state.redactMarks.map((m) => m.page))]; state.redactMarks = []; pages.forEach((p) => drawAnnotsForPage(p)); updateRedactButtons(); } // Áp dụng redact: XOÁ THẬT nội dung trong các vùng đã đánh dấu → file mới. async function applyRedactions() { if (!state.redactMarks.length) return; const out = await invoke("pick_save_pdf"); if (!out) return; // Gom theo trang: [{page, rects: [[l,b,r,t], ...]}] const byPage = new Map(); for (const m of state.redactMarks) { if (!byPage.has(m.page)) byPage.set(m.page, []); byPage.get(m.page).push([m.rect.left, m.rect.bottom, m.rect.right, m.rect.top]); } const areas = [...byPage.entries()].map(([page, rects]) => ({ page, rects })); try { const n = await invoke("redact_apply", { input: state.path, areas, output: out, password: null }); clearRedactMarks(); setTool(null); $("status").textContent = `Đã redact (xoá thật) ${n} đối tượng → ${shortName(out)}`; loadDocument(out); } catch (e) { $("secHint").textContent = "Lỗi redact: " + e; } } function openEncryptDialog() { const box = openModal("Đặt mật khẩu (AES-256)", `
`); box.querySelector("#secEncCancel").addEventListener("click", closeModal); box.querySelector("#secEncOk").addEventListener("click", async () => { const p1 = box.querySelector("#secPw1").value; const p2 = box.querySelector("#secPw2").value; const err = box.querySelector("#secEncErr"); if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; } if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; } const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("security_encrypt", { input: state.path, output: out, userPassword: p1, ownerPassword: box.querySelector("#secPwOwner").value, allowPrint: box.querySelector("#secPermPrint").checked, allowModify: box.querySelector("#secPermModify").checked, allowExtract: box.querySelector("#secPermExtract").checked, allowAnnotate: box.querySelector("#secPermAnnotate").checked, }); closeModal(); $("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`; $("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên."; } catch (e) { err.textContent = "Lỗi: " + e; } }); } function openDecryptDialog() { const box = openModal("Gỡ mật khẩu", `

Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.

`); box.querySelector("#secDecCancel").addEventListener("click", closeModal); box.querySelector("#secDecOk").addEventListener("click", async () => { const pw = box.querySelector("#secDecPw").value; const err = box.querySelector("#secDecErr"); if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; } const inp = await invoke("pick_pdf"); if (!inp) return; const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("security_decrypt", { input: inp, password: pw, output: out }); closeModal(); $("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`; loadDocument(out); } catch (e) { err.textContent = "Lỗi (mật khẩu sai?): " + e; } }); } async function stripMetadataAction() { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("security_strip_metadata", { input: state.path, output: out }); $("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`; loadDocument(out); } catch (e) { $("secHint").textContent = "Lỗi xoá metadata: " + e; } } async function optimizeSaveAction() { const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("security_optimize", { input: state.path, output: out }); $("status").textContent = `Đã lưu tối ưu (nén + dọn rác) → ${shortName(out)}`; loadDocument(out); } catch (e) { $("secHint").textContent = "Lỗi lưu tối ưu: " + e; } } // --- Chữ ký số (Phase 5 iteration 2) --- function openCreateIdDialog() { const box = openModal("Tạo Digital ID tự ký", `

Tạo chứng chỉ tự ký (RSA-2048) để ký thử. Nếu có chữ ký của tổ chức phát hành (PFX/PEM) thì dùng thẳng nó ở bước Ký số.

`); box.querySelector("#idCancel").addEventListener("click", closeModal); box.querySelector("#idOk").addEventListener("click", async () => { const cn = box.querySelector("#idCn").value.trim(); if (!cn) { box.querySelector("#idErr").textContent = "Cần nhập tên."; return; } const out = await invoke("pick_save_pem"); if (!out) return; try { await invoke("sig_create_id", { commonName: cn, output: out }); closeModal(); $("secHint").textContent = `Đã tạo Digital ID → ${shortName(out)} (dùng ở bước Ký số)`; } catch (e) { box.querySelector("#idErr").textContent = "Lỗi: " + e; } }); } function openSignDialog() { const box = openModal("Ký số tài liệu", `
`); let idPath = null; box.querySelector("#sgPickId").addEventListener("click", async () => { const p = await invoke("pick_any_file"); if (p) { idPath = p; box.querySelector("#sgId").value = shortName(p); } }); box.querySelector("#sgCancel").addEventListener("click", closeModal); box.querySelector("#sgOk").addEventListener("click", async () => { const name = box.querySelector("#sgName").value.trim(); const reason = box.querySelector("#sgReason").value.trim(); const err = box.querySelector("#sgErr"); if (!name) { err.textContent = "Cần nhập tên người ký."; return; } if (!idPath) { err.textContent = "Cần chọn Digital ID (.pem)."; return; } const out = await invoke("pick_save_pdf"); if (!out) return; try { await invoke("sig_sign", { input: state.path, idPem: idPath, reason, signerName: name, output: out }); closeModal(); $("status").textContent = `Đã ký số → ${shortName(out)}`; loadDocument(out); // Tự kiểm tra chữ ký vừa tạo. verifySignaturesAction(out); } catch (e) { err.textContent = "Lỗi ký: " + e; } }); } async function verifySignaturesAction(pathOverride) { const target = typeof pathOverride === "string" ? pathOverride : state.path; let checks; try { checks = await invoke("sig_verify", { input: target }); } catch (e) { $("secHint").textContent = "Lỗi kiểm tra: " + e; return; } const rows = checks.length ? checks .map((c, i) => { const badge = c.valid ? '✓ Hợp lệ' : '✗ Không hợp lệ'; const detail = []; if (!c.cryptoValid) detail.push("chữ ký sai"); if (!c.digestMatches) detail.push("nội dung đã bị sửa"); if (!c.coversDocument) detail.push("có phần thêm sau khi ký"); return `${i + 1}${(c.signer || "").replace(/${badge} ${detail.join("; ")}`; }) .join("") : `Tài liệu chưa có chữ ký số nào.`; const box = openModal("Kết quả kiểm tra chữ ký", ` ${rows}
#Người kýTrạng tháiGhi chú
`); box.querySelector("#sigClose").addEventListener("click", closeModal); } // ---------- Sự kiện ---------- $("openBtn").addEventListener("click", openFile); // Kéo-thả file PDF từ File Explorer vào cửa sổ → mở luôn (chuẩn desktop app). // Tauri v2 bắn sự kiện drag-drop toàn cửa sổ với danh sách đường dẫn thật. if (window.__TAURI__.event) { window.__TAURI__.event.listen("tauri://drag-drop", (e) => { const paths = (e.payload && e.payload.paths) || []; const pdf = paths.find((p) => /\.pdf$/i.test(p)); if (!pdf) { if (paths.length) $("status").textContent = "Chỉ mở được file .pdf"; return; } if (state.editMode) exitEditMode(); if (state.organizeMode) exitOrganizeMode(); loadDocument(pdf); }); } $("zoomIn").addEventListener("click", () => setZoom(state.zoom * 1.25)); $("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25)); $("zoomFit").addEventListener("click", fitWidth); $("zoomFitPage").addEventListener("click", fitPage); buildZoomSelect(); $("zoomSelect").addEventListener("change", (e) => setZoom(Number(e.target.value) / 100)); $("copyPage").addEventListener("click", copyCurrentPage); $("pagePrev").addEventListener("click", gotoPrevPage); $("pageNext").addEventListener("click", gotoNextPage); $("pageInput").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); goToPageInput(); } }); $("pageInput").addEventListener("blur", goToPageInput); $("searchBox").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 300); }); $("searchBox").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); gotoHit(state.hitIdx + (e.shiftKey ? -1 : 1)); } }); $("searchCase").addEventListener("change", runSearch); $("searchNext").addEventListener("click", () => gotoHit(state.hitIdx + 1)); $("searchPrev").addEventListener("click", () => gotoHit(state.hitIdx - 1)); $("viewport").addEventListener("scroll", () => { updateCurrentPage(); if (state.hits.length) drawHighlightsForVisible(); closeColorPopover(); closeNotePopup(); finishEditing(); }); // Ctrl + lăn chuột: zoom tại vị trí con trỏ. // Gộp nhiều nấc lăn trong 1 khung hình (rAF) để mượt, không dồn render. let wheelFactor = 1; let wheelRAF = 0; let wheelX = 0; let wheelY = 0; $("viewport").addEventListener( "wheel", (e) => { if (!e.ctrlKey) return; e.preventDefault(); wheelFactor *= e.deltaY < 0 ? 1.1 : 1 / 1.1; wheelX = e.clientX; wheelY = e.clientY; if (!wheelRAF) { wheelRAF = requestAnimationFrame(() => { wheelRAF = 0; const f = wheelFactor; wheelFactor = 1; zoomAtPoint(state.zoom * f, wheelX, wheelY); }); } }, { passive: false } ); $("tabThumbs").addEventListener("click", () => switchTab("thumbs")); $("tabOutline").addEventListener("click", () => switchTab("outline")); $("tabComments").addEventListener("click", () => switchTab("comments")); function switchTab(which) { for (const [tab, panel] of [["tabThumbs", "thumbs"], ["tabOutline", "outline"], ["tabComments", "comments"]]) { const on = panel === which; $(tab).classList.toggle("active", on); $(panel).classList.toggle("hidden", !on); } } // Công cụ chú thích document.querySelectorAll(".atool").forEach((b) => b.addEventListener("click", () => setTool(b.dataset.tool)) ); function refreshColorSwatch() { const sw = $("annotSw"); if (sw) sw.style.background = rgbCss(state.color); } refreshColorSwatch(); $("annotColorBtn").addEventListener("click", () => { openColorPopover($("annotColorBtn"), state.color, (rgb) => { state.color = rgb; refreshColorSwatch(); // áp cho annotation đang chọn (nếu có) if (state.selectedId != null) { const s = state.annotSpecs.find((x) => x.id === state.selectedId); if (s) { pushUndo(); s.color = rgb.slice(); drawAnnotsForPage(s.pageIndex); buildComments(); } } }); }); $("saveAnnots").addEventListener("click", saveAnnots); $("undoBtn").addEventListener("click", () => (state.editMode ? editUndo() : undo())); $("redoBtn").addEventListener("click", () => (state.editMode ? editRedo() : redo())); $("pages").addEventListener("mousedown", onPagesMouseDown); $("pages").addEventListener("dblclick", onViewerDblClick); window.addEventListener("mousemove", onPagesMouseMove); window.addEventListener("mouseup", onPagesMouseUp); $("organizeModeBtn").addEventListener("click", toggleOrganizeMode); $("orgInsert").addEventListener("click", openInsertDialog); $("orgDelete").addEventListener("click", orgDeleteSelected); $("orgRotateL").addEventListener("click", () => orgRotateSelected(-90)); $("orgRotateR").addEventListener("click", () => orgRotateSelected(90)); $("orgExtract").addEventListener("click", openExtractDialog); $("orgReplace").addEventListener("click", openReplaceDialog); $("orgMerge").addEventListener("click", openMergeDialog); $("orgSplit").addEventListener("click", openSplitDialog); $("orgWatermark").addEventListener("click", openWatermarkDialog); $("orgHeaderFooter").addEventListener("click", openHeaderFooterDialog); $("orgSave").addEventListener("click", orgSaveChanges); $("modalOverlay").addEventListener("click", (e) => { if (e.target.id === "modalOverlay") closeModal(); }); // OCR & Chuyển đổi (Phase 7) $("convModeBtn").addEventListener("click", toggleConvMode); $("cvOcr").addEventListener("click", runOcrAction); $("cvPng").addEventListener("click", exportPngAction); $("cvTxt").addEventListener("click", exportTxtAction); $("cvDocx").addEventListener("click", exportDocxAction); $("cvOffice").addEventListener("click", officeToPdfAction); // Form (Phase 6) $("formModeBtn").addEventListener("click", toggleFormMode); $("fmFill").addEventListener("click", openFillForm); $("fmCreate").addEventListener("click", openCreateFieldDialog); $("fmFlatten").addEventListener("click", flattenFormAction); $("fmExportFdf").addEventListener("click", () => exportFormData("fdf")); $("fmExportCsv").addEventListener("click", () => exportFormData("csv")); $("fmImportFdf").addEventListener("click", importFormFdf); // Bảo mật (Phase 5) $("secModeBtn").addEventListener("click", toggleSecMode); $("secRedactApply").addEventListener("click", applyRedactions); $("secRedactClear").addEventListener("click", clearRedactMarks); $("secEncrypt").addEventListener("click", openEncryptDialog); $("secDecrypt").addEventListener("click", openDecryptDialog); $("secStripMeta").addEventListener("click", stripMetadataAction); $("secOptimize").addEventListener("click", optimizeSaveAction); $("secCreateId").addEventListener("click", openCreateIdDialog); $("secSign").addEventListener("click", openSignDialog); $("secVerify").addEventListener("click", () => verifySignaturesAction()); // Sửa nội dung (Phase 4) $("editModeBtn").addEventListener("click", toggleEditMode); $("edAddText").addEventListener("click", () => { state.editArm = state.editArm === "text" ? null : "text"; $("edAddText").classList.toggle("armed", state.editArm === "text"); $("editOverlay").classList.toggle("armed", !!state.editArm); $("editHint").textContent = state.editArm === "text" ? "Bấm lên trang để đặt chữ" : ""; }); $("edAddImage").addEventListener("click", armAddImage); $("edDelete").addEventListener("click", deleteSelectedEditObject); $("edReplaceImage").addEventListener("click", replaceSelectedImage); // Mọi thay đổi thuộc tính chỉ gửi ĐÚNG field đó — engine giữ nguyên font khi // không cần thay (chuẩn Foxit: đổi cỡ/màu không được đổi font). $("edFontSize").addEventListener("change", () => { const size = Number($("edFontSize").value); if (size > 0) applyTextPropToSelected({ fontSize: size }); }); $("edFontFamily").addEventListener("change", () => { const fam = $("edFontFamily").value; if (fam) applyTextPropToSelected({ fontFamily: fam }); }); $("edBold").addEventListener("click", () => { const o = state.editObjects.find((x) => x.index === state.editSel); if (o) applyTextPropToSelected({ bold: !o.fontBold }); }); $("edItalic").addEventListener("click", () => { const o = state.editObjects.find((x) => x.index === state.editSel); if (o) applyTextPropToSelected({ italic: !o.fontItalic }); }); $("edColorBtn").addEventListener("click", () => { openColorPopover($("edColorBtn"), state.editColor, (rgb) => { state.editColor = rgb; $("edSw").style.background = rgbCss(rgb); applyTextPropToSelected({ color: [rgb[0], rgb[1], rgb[2], 255] }); }); }); $("edSave").addEventListener("click", saveEdits); $("editOverlay").addEventListener("click", onEditStageClick); // Đúp vào KHE giữa các khung (PDF cắt run rời rạc) vẫn mở ô sửa tại chữ gần đó. $("editOverlay").addEventListener("dblclick", (e) => { if (state.editArm) return; if (e.target.closest && e.target.closest(".edit-inline")) return; const img = $("editImg"); const r = img.getBoundingClientRect(); const p = state.pages[state.editPage]; const x = (e.clientX - r.left) / state.editScale; const y = p.heightPt - (e.clientY - r.top) / state.editScale; const hit = pickTextAt(state.editObjects, x, y); if (hit) startTextEdit(hit, e); }); // Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom, // tìm kiếm, xoá/bỏ chọn annotation. window.addEventListener("keydown", (e) => { const typing = e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName); if (e.key === "Escape") { if (!$("modalOverlay").classList.contains("hidden")) { closeModal(); return; } if (state.editMode && state.editArm) { state.editArm = null; state.editPendingImage = null; $("edAddText").classList.remove("armed"); $("edAddImage").classList.remove("armed"); $("editOverlay").classList.remove("armed"); $("editHint").textContent = ""; return; } finishEditing(); closeNotePopup(); closeColorPopover(); deselectAnnot(); setTool(null); return; } if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.editMode && state.editSel != null) { e.preventDefault(); deleteSelectedEditObject(); return; } if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.organizeMode && state.orgSelected.size) { e.preventDefault(); orgDeleteSelected(); return; } if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.selectedId != null) { e.preventDefault(); deleteSpec(state.selectedId); return; } if (e.ctrlKey && (e.key === "f" || e.key === "F")) { e.preventDefault(); $("searchBox").focus(); $("searchBox").select(); return; } if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); setZoom(state.zoom * 1.25); return; } if (e.ctrlKey && e.key === "-") { e.preventDefault(); setZoom(state.zoom / 1.25); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); setZoom(1); return; } // Ctrl+Z hoàn tác / Ctrl+Y hoặc Ctrl+Shift+Z làm lại — không chặn khi đang gõ // trong ô text (để trình duyệt tự xử lý undo cấp ký tự trong contenteditable). if (e.ctrlKey && !e.shiftKey && (e.key === "z" || e.key === "Z") && !typing) { e.preventDefault(); if (state.editMode) editUndo(); else undo(); return; } if (e.ctrlKey && (e.key === "y" || e.key === "Y" || (e.shiftKey && (e.key === "z" || e.key === "Z"))) && !typing) { e.preventDefault(); if (state.editMode) editRedo(); else redo(); return; } if (typing) return; if (e.key === "PageDown") { e.preventDefault(); gotoNextPage(); return; } if (e.key === "PageUp") { e.preventDefault(); gotoPrevPage(); return; } if (e.key === "Home") { e.preventDefault(); goToPage(0); return; } if (e.key === "End") { e.preventDefault(); goToPage(state.pages.length - 1); return; } }); window.addEventListener("DOMContentLoaded", boot);