Files
FoFreeXit/app/src/main.js
T
adminandClaude Fable 5 f10cb7df2b
Build Windows portable / build (push) Canceled after 0s
DONG GOI bo font Noto theo app: khong thoa hiep font he thong o bat ky tang nao
User yeu cau: khach hang khong co font chuan san — phan mem phai tu chuan bi
font, khong duoc thay bang font "gan giong". Truoc do font nhung cua PDF da
dung cho o sua (build 35), nhung con 3 khe ho roi ve font he thong: font
khong nhung trong PDF, font CFF khong va duoc, va KY TU MOI GO VAO khong co
trong subset (subset chi chua glyph da dung).

- scripts/fetch-fonts.ps1: tai 10 file Noto hinted static (Sans/Serif/Mono,
  du Regular/Bold/Italic/BoldItalic, phu tieng Viet, ~6.7MB) + OFL license
  vao fonts/ (gitignore; CI cache theo hash script).
- CI: fetch fonts + dong fonts/ vao zip portable (canh exe).
- Tauri: main() dat FOFREEXIT_FONTS_PATH (fonts/ canh exe, dev thi goc
  workspace); command app_font_data(kind) tra base64 (whitelist 10 kind).
- UI: FontFace "ff-app-{sans|serif|mono}" dang ky theo weight/style, dung
  GIUA stack: font nhung cua PDF -> Noto dong goi -> stack he thong (luoi
  cuoi). Phan loai serif/sans/mono tu cssFontStack. Nap truoc cung font
  nhung khi vao trang sua.
- Engine find_font_bytes (duong ghi file/reflow can font coverage): uu tien
  tuyet doi fonts/ dong goi qua env — ket qua LUU ra giong nhau tren moi may
  (truoc: lay Arial/Segoe cua Windows, may khach thieu font la khac ket qua).

E2E ke tiep (build 36): go ky tu ngoai subset trong o sua -> 100% glyph phai
tu font custom (nhung/dong goi), 0 glyph he thong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:12:26 +07:00

4396 lines
174 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
editZoom: 1, // zoom stage sửa (1.0 = 100% như viewer)
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 = currentPageIndex() + 1; return; }
const idx = Math.max(0, Math.min(state.pages.length - 1, n - 1));
goToPage(idx);
$("pageInput").value = idx + 1;
}
function currentPageIndex() { return state.editMode ? state.editPage : state.current; }
function gotoPrevPage() { goToPage(Math.max(0, currentPageIndex() - 1)); }
function gotoNextPage() { goToPage(Math.min(state.pages.length - 1, currentPageIndex() + 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 = `<img alt="t${p.index}" style="height:${thumbH}px"/><div class="n">${p.index + 1}</div>`;
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 = `<div class="oitem" style="color:#777">Không có outline</div>`;
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) {
// Chế độ Sửa nội dung: chuyển trang = nạp trang khác của editBase.
if (state.editMode) { switchEditPage(idx); return; }
const slot = state.slots[idx];
if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Chuyển trang ACTIVE trong chế độ Sửa nội dung (undo/redo dùng chung
// editBase nên vẫn giữ nguyên các thay đổi đã áp ở trang khác).
// `scrollBlock`: "start" (nav nút/outline), "nearest" (đúp tại chỗ — không
// giật màn hình), null (người dùng đang tự cuộn — không cuộn hộ).
function switchEditPage(idx, scrollBlock) {
const i = Math.max(0, Math.min(state.pages.length - 1, idx));
if (i === state.editPage) {
// Vẫn cuộn về trang active (gõ lại số trang sau khi đã cuộn đi xa).
const slot = (state.editSlots || [])[i];
if (slot && scrollBlock !== null) {
slot.scrollIntoView({ block: scrollBlock || "start", behavior: "smooth" });
}
return;
}
const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove(); // đóng ô sửa đang mở, không lưu
state.editPage = i;
state.editSel = null;
selectEditObject(-1);
$("pageInput").value = i + 1;
loadEditPage();
const slot = (state.editSlots || [])[i];
if (slot && scrollBlock !== null) {
slot.scrollIntoView({ block: scrollBlock || "start", behavior: "smooth" });
}
}
// ---------- Chế độ sửa NHIỀU TRANG ----------
// Cột đủ mọi trang như viewer: trang active = #editPageWrap (img + overlay
// sửa được), trang khác render ảnh read-only LƯỜI khi cuộn tới. Đúp vào
// trang read-only → trang đó thành active + mở ô sửa ngay tại điểm đúp.
let editWrapEl = null; // #editPageWrap — giữ tham chiếu sống khi dựng lại cột
let editRoObserver = null;
const editVisibleRo = new Set(); // index các slot read-only đang trong khung nhìn
// Ảnh read-only hợp lệ khi CẢ editBase lẫn zoom chưa đổi từ lúc render.
function editRoStamp() {
return state.editBase + "|" + state.editZoom.toFixed(3);
}
function sizeEditSlot(slot, p) {
const w = editStageWidth(p);
const h = Math.round((w * p.heightPt) / p.widthPt);
slot.style.width = w + "px";
slot.style.height = h + "px";
// Slot active cao theo ảnh thật (CSS :has cho height auto) — nhưng lúc ảnh
// CHƯA load mà để cao 0 thì cuộn ban đầu lệch cả cột và listener cuộn tự
// chuyển nhầm trang active → luôn giữ tối thiểu chiều cao ước tính.
slot.style.minHeight = h + "px";
}
function buildEditColumn() {
const col = $("editPagesCol");
editWrapEl = editWrapEl || $("editPageWrap");
if (editWrapEl.parentElement) editWrapEl.remove(); // tách ra trước khi xoá cột
col.innerHTML = "";
editVisibleRo.clear();
if (editRoObserver) editRoObserver.disconnect();
state.editSlots = [];
for (const p of state.pages) {
const slot = document.createElement("div");
slot.className = "edit-slot";
slot.dataset.page = String(p.index);
sizeEditSlot(slot, p);
col.appendChild(slot);
state.editSlots.push(slot);
}
placeEditWrap();
editRoObserver = new IntersectionObserver(
(entries) => {
for (const en of entries) {
const idx = Number(en.target.dataset.page);
if (en.isIntersecting) {
editVisibleRo.add(idx);
fillRoSlot(idx);
} else {
editVisibleRo.delete(idx);
}
}
},
{ root: $("editStage"), rootMargin: "300px" }
);
for (const slot of state.editSlots) editRoObserver.observe(slot);
}
// Đặt wrap sửa được vào slot của trang active; slot cũ quay về ảnh read-only.
function placeEditWrap() {
const slot = (state.editSlots || [])[state.editPage];
if (!slot || slot.contains(editWrapEl)) return;
const old = editWrapEl.parentElement;
const ro = slot.querySelector("img.edit-ro");
if (ro) ro.remove();
slot.dataset.rendered = ""; // slot này giờ hiển thị bằng wrap
slot.appendChild(editWrapEl);
if (old && old.classList && old.classList.contains("edit-slot")) {
old.dataset.rendered = "";
fillRoSlot(Number(old.dataset.page));
}
}
// Render ảnh read-only cho 1 trang không-active nếu đang thấy và ảnh cũ đã
// stale; stamp chống render trùng khi observer + refresh gọi chồng nhau.
async function fillRoSlot(idx) {
if (idx === state.editPage) return;
const slot = (state.editSlots || [])[idx];
if (!slot || !editVisibleRo.has(idx)) return;
const stamp = editRoStamp();
if (slot.dataset.rendered === stamp) return;
slot.dataset.rendered = stamp;
try {
const url = await invoke("render_page", {
path: state.editBase, page: idx, width: editStageWidth(state.pages[idx]),
});
// Trong lúc render: trang này thành active / có lượt mới hơn → bỏ kết quả.
if (idx === state.editPage || slot.dataset.rendered !== stamp) return;
let img = slot.querySelector("img.edit-ro");
if (!img) {
img = document.createElement("img");
img.className = "edit-ro";
img.alt = "trang " + (idx + 1);
slot.appendChild(img);
}
img.src = url;
} catch (_) {
slot.dataset.rendered = ""; // để lần cuộn/refresh sau thử lại
}
}
// Sau mỗi op/undo/redo/zoom: các trang read-only đang thấy render lại nếu cần
// (stamp đổi theo editBase + zoom nên gọi thoải mái, không render thừa).
function refreshRoSlots() {
for (const idx of editVisibleRo) fillRoSlot(idx);
}
// ---------- Zoom ----------
// Bề rộng px của stage sửa theo mức zoom hiện tại (1.0 = 100% như viewer).
function editStageWidth(p) {
return Math.max(200, Math.round(p.widthPt * PT_PER_PX * state.editZoom));
}
// Zoom chế độ Sửa nội dung: co giãn ảnh + overlay NGAY (mượt), render nét
// bằng PDFium sau khi ngừng thao tác.
function setEditZoom(z) {
state.editZoom = Math.max(0.25, Math.min(4, z));
const p = state.pages[state.editPage];
if (!p) return;
const w = editStageWidth(p);
state.editScale = w / p.widthPt;
$("editImg").style.width = w + "px";
// Mọi slot trong cột co giãn theo ngay (ảnh cũ scale CSS, render nét sau).
if (state.editSlots) {
for (const pg of state.pages) sizeEditSlot(state.editSlots[pg.index], pg);
}
buildEditOverlay();
updateZoomLabel();
scheduleEditSharpRender();
}
let editSharpTimer = null;
function scheduleEditSharpRender() {
clearTimeout(editSharpTimer);
editSharpTimer = setTimeout(async () => {
try {
const seq = editLoadSeq;
const p = state.pages[state.editPage];
const url = await invoke("render_page", {
path: state.editBase, page: state.editPage, width: editStageWidth(p),
});
if (seq !== editLoadSeq) return; // trang/file đã đổi trong lúc render
$("editImg").src = url;
} catch (_) { /* giữ ảnh cũ (đã co giãn CSS) */ }
refreshRoSlots(); // các trang read-only đang thấy cũng render nét ở zoom mới
}, 220);
}
function setZoom(z) {
if (state.editMode) { setEditZoom(z); return; }
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.editMode ? state.editZoom : 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;
if (state.editMode) {
const p = state.pages[state.editPage];
setEditZoom(($("editStage").clientWidth - 48) / (p.widthPt * PT_PER_PX));
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;
if (state.editMode) {
const st = $("editStage");
const p = state.pages[state.editPage];
setEditZoom(Math.min(
(st.clientWidth - 48) / (p.widthPt * PT_PER_PX),
(st.clientHeight - 48) / (p.heightPt * PT_PER_PX)
));
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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 = `<span class="sw" style="background:${rgbCss(spec.color)}"></span>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 = `
<div class="np-head"><span>Ghi chú</span><span class="np-close">✕</span></div>
<textarea class="np-text" placeholder="Nhập nội dung ghi chú…">${escapeHtml(spec.contents || "")}</textarea>
<div class="np-foot">
<button class="np-color"><span class="sw" style="background:${rgbCss(spec.color)}"></span>Màu</button>
<button class="np-del">🗑 Xoá</button>
</div>`;
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… <input type="color" value="${rgbToHex(currentRgb)}">`;
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 <span> đị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 = `<div class="empty">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".</div>`;
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 =
`<span class="cdel">✕</span>` +
`<span class="csw" style="background:${rgbCss(s.color)}"></span>` +
`<span class="ckind">${labels[s.kind] || s.kind}</span> · trang ${s.pageIndex + 1}` +
(s.contents ? `<br><span class="ctxt">${escapeHtml(s.contents)}</span>` : "");
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 = `<h3>${title}</h3>${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 =
`<p class="status">Đ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.</p>`;
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 =
`<div class="org-thumb-wrap" style="height:${wrapH}px">` +
(entry.kind === "blank"
? `<div style="width:100%;height:100%;background:#fff"></div>`
: `<img alt="p${i}"/>`) +
(entry.crop ? `<div class="org-crop-mark"></div>` : "") +
`</div><div class="org-n">${i + 1}</div>`;
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", `
<div class="radiorow">
<label><input type="radio" name="insKind" value="blank" checked> Trang trắng</label>
<label><input type="radio" name="insKind" value="file"> Từ file…</label>
</div>
<div id="insBlankOpts">
<label>Cỡ giấy</label>
<select id="insPaper">
<option value="612x792">Letter</option>
<option value="595x842">A4</option>
<option value="custom">Tuỳ chọn…</option>
</select>
<div class="row" id="insCustomSize" style="display:none">
<div><label>Rộng (pt)</label><input type="number" id="insW" value="612"></div>
<div><label>Cao (pt)</label><input type="number" id="insH" value="792"></div>
</div>
</div>
<div id="insFileOpts" style="display:none">
<label>File nguồn</label>
<button id="insPickFile" type="button">📂 Chọn file…</button>
<span id="insFileName" class="status"></span>
<label>Trang (vd 1-3,5 — rỗng = tất cả)</label>
<input type="text" id="insRange" placeholder="tất cả">
</div>
<label>Vị trí</label>
<div class="radiorow">
<label><input type="radio" name="insPos" value="before" ${state.orgSelected.size ? "" : "disabled"}> Trước trang đang chọn</label>
<label><input type="radio" name="insPos" value="after" ${state.orgSelected.size ? "checked" : "disabled"}> Sau trang đang chọn</label>
<label><input type="radio" name="insPos" value="end" ${state.orgSelected.size ? "" : "checked"}> Cuối tài liệu</label>
</div>
<div class="err" id="insErr"></div>
<div class="foot"><button id="insCancel">Huỷ</button><button id="insOk" class="primary">Chèn</button></div>
`);
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", `
<p>Trích ${state.orgSelected.size} trang đã chọn ra file PDF mới.</p>
<label><input type="checkbox" id="extDeleteAfter"> Xoá các trang này khỏi tài liệu sau khi trích</label>
<div class="err" id="extErr"></div>
<div class="foot"><button id="extCancel">Huỷ</button><button id="extOk" class="primary">Trích…</button></div>
`);
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", `
<p>Thay nội dung ${state.orgSelected.size} trang đã chọn bằng trang từ file khác.</p>
<label>File nguồn</label>
<button id="repPickFile" type="button">📂 Chọn file…</button>
<span id="repFileName" class="status"></span>
<label>Trang nguồn (vd 1-3,5 — rỗng = tất cả)</label>
<input type="text" id="repRange" placeholder="tất cả">
<div class="err" id="repErr"></div>
<div class="foot"><button id="repCancel">Huỷ</button><button id="repOk" class="primary">Thay</button></div>
`);
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", `
<button id="mrgAdd" type="button">➕ Thêm file…</button>
<div id="mrgList" style="margin-top:8px;"></div>
<div class="err" id="mrgErr"></div>
<div class="foot"><button id="mrgCancel">Huỷ</button><button id="mrgOk" class="primary">Trộn…</button></div>
`);
function renderList() {
box.querySelector("#mrgList").innerHTML = files.map((f, i) =>
`<div class="row" style="margin-bottom:4px;align-items:center;">` +
`<span style="flex:3">${i + 1}. ${shortName(f)}</span>` +
`<button data-up="${i}" type="button" ${i === 0 ? "disabled" : ""}>↑</button>` +
`<button data-down="${i}" type="button" ${i === files.length - 1 ? "disabled" : ""}>↓</button>` +
`<button data-rm="${i}" type="button">✕</button></div>`
).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", `
<label>Mỗi file tối đa (số trang)</label>
<input type="number" id="splitN" value="1" min="1">
<div class="err" id="splitErr"></div>
<div class="foot"><button id="splitCancel">Huỷ</button><button id="splitOk" class="primary">Tách…</button></div>
`);
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 : ""}
<label>Nội dung</label>
<input type="text" id="wmText" value="CONFIDENTIAL">
<div class="row">
<div><label>Cỡ chữ</label><input type="number" id="wmSize" value="36"></div>
<div><label>Màu (r,g,b)</label><input type="text" id="wmColor" value="200,0,0"></div>
<div><label>Độ mờ (0-255)</label><input type="number" id="wmAlpha" value="120" min="0" max="255"></div>
</div>
<div class="row">
<label><input type="checkbox" id="wmBold"> Đậm</label>
<label><input type="checkbox" id="wmItalic"> Nghiêng</label>
</div>
<label>Góc xoay (độ)</label>
<input type="number" id="wmRotate" value="45">
<label>Vị trí</label>
<div class="anchor9" id="wmAnchor">
${["top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right"]
.map((a) => `<button type="button" data-a="${a}" class="${a === "center" ? "cur" : ""}">●</button>`).join("")}
</div>
<label>Trang áp dụng (rỗng = tất cả, vd 1-3,5)</label>
<input type="text" id="wmPages" placeholder="tất cả">
<div class="foot">
<button id="wmPreview" type="button">👁 Xem trước</button>
<button id="wmCancel">Huỷ</button><button id="wmOk" class="primary">Áp dụng</button>
</div>
<div class="err" id="wmErr"></div>
`);
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 : ""}
<div class="row">
<div><label>Trên-trái</label><input type="text" id="hfTL"></div>
<div><label>Trên-giữa</label><input type="text" id="hfTC"></div>
<div><label>Trên-phải</label><input type="text" id="hfTR"></div>
</div>
<div class="row">
<div><label>Dưới-trái</label><input type="text" id="hfBL"></div>
<div><label>Dưới-giữa</label><input type="text" id="hfBC" value="Trang {page}/{total}"></div>
<div><label>Dưới-phải</label><input type="text" id="hfBR"></div>
</div>
<p class="status">Chèn vào ô đang focus:
<button type="button" data-tok="{page}">{page}</button>
<button type="button" data-tok="{total}">{total}</button>
<button type="button" data-tok="{date}">{date}</button>
</p>
<div class="row">
<div><label>Cỡ chữ</label><input type="number" id="hfSize" value="10"></div>
<div><label>Lề (pt)</label><input type="number" id="hfMargin" value="20"></div>
<div><label>Màu (r,g,b)</label><input type="text" id="hfColor" value="0,0,0"></div>
</div>
<label>Trang áp dụng (rỗng = tất cả)</label>
<input type="text" id="hfPages" placeholder="tất cả">
<div class="foot">
<button id="hfPreview" type="button">👁 Xem trước</button>
<button id="hfCancel">Huỷ</button><button id="hfOk" class="primary">Áp dụng</button>
</div>
<div class="err" id="hfErr"></div>
`);
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", `
<div class="row">
<div><label>Trái (pt)</label><input type="number" id="cropL" value="${m.left.toFixed(1)}"></div>
<div><label>Phải (pt)</label><input type="number" id="cropR" value="${m.right.toFixed(1)}"></div>
</div>
<div class="row">
<div><label>Trên (pt)</label><input type="number" id="cropT" value="${m.top.toFixed(1)}"></div>
<div><label>Dưới (pt)</label><input type="number" id="cropB" value="${m.bottom.toFixed(1)}"></div>
</div>
<div class="radiorow">
<label><input type="radio" name="cropScope" value="this" checked> Trang này</label>
<label><input type="radio" name="cropScope" value="all"> Tất cả trang</label>
</div>
<p class="status">Á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.</p>
<div class="foot"><button id="cropCancel">Huỷ</button><button id="cropOk" class="primary">Áp dụng</button></div>
`);
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;
// Kế thừa mức zoom đang xem để không "giật" cỡ hiển thị khi vào chế độ sửa.
state.editZoom = Math.max(0.25, Math.min(4, state.zoom));
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);
resetDocFonts(); // font nhúng nạp theo phiên sửa (subset mỗi file khác nhau)
buildEditColumn();
// Đứng ngay tại trang vào sửa (không smooth — vừa mở chế độ, chưa có ngữ cảnh cuộn).
const slot0 = state.editSlots[state.editPage];
if (slot0) slot0.scrollIntoView({ block: "start", behavior: "instant" });
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.
// Guard số thứ tự: chuyển trang/zoom nhanh tạo nhiều lượt nạp song song —
// chỉ lượt MỚI NHẤT được ghi vào DOM/state (lượt chậm về sau bị bỏ).
let editLoadSeq = 0;
async function loadEditPage() {
const mySeq = ++editLoadSeq;
const p = state.pages[state.editPage];
const stageW = editStageWidth(p);
state.editScale = stageW / p.widthPt;
try {
const [url, objs] = await Promise.all([
invoke("render_page", { path: state.editBase, page: state.editPage, width: stageW }),
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]);
if (mySeq !== editLoadSeq) return; // đã có lượt nạp mới hơn — bỏ lượt này
placeEditWrap(); // wrap sửa được nằm đúng slot của trang active
// Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text
// được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
// cần mở gói trang.
const img = $("editImg");
img.src = url;
img.style.width = stageW + "px";
state.editObjects = objs;
// Nạp trước font nhúng của mọi run text (dedup theo tên font) + font Noto
// đóng gói cùng phân loại — đến lúc đúp mở ô sửa mọi face đã sẵn.
for (const o of objs) {
if (o.kind !== "text") continue;
ensureDocFont(o, state.editBase, state.editPage);
ensureAppFont(fontClassOf(o.fontFamily), o.fontBold, o.fontItalic);
}
buildEditOverlay();
$("pageInput").value = state.editPage + 1;
updateZoomLabel();
$("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
$("edDiscard").disabled = state.editBase === state.path;
updateEditUndoButtons();
refreshRoSlots(); // editBase có thể vừa đổi (op/undo/redo) → trang khác render lại
// Đú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: <family gốc>)" — 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",
};
// ---------- Font NHÚNG của tài liệu cho ô sửa ----------
// Máy người dùng thường KHÔNG cài font của PDF: WebView thay bằng font hệ
// thống gần giống (NotoSansMono-Bold → Consolas-Bold nét mảnh — user thấy
// như "mất in đậm"; NotoSans → Arial). Nạp bytes font nhúng từ engine vào
// WebView (FontFace) rồi đặt family đó LÊN ĐẦU stack của ô sửa → chữ trong ô
// đúng font thật của tài liệu. Glyph thiếu trong subset tự rơi xuống stack cũ.
let docFontSeq = 0; // đổi phiên sửa = bỏ face cũ (subset khác coverage)
const docFonts = new Map(); // fontName -> family đã nạp | null (lỗi) | Promise
const docFontFaces = [];
function resetDocFonts() {
docFontSeq++;
for (const f of docFontFaces) { try { document.fonts.delete(f); } catch (_) {} }
docFontFaces.length = 0;
docFonts.clear();
}
// Nạp lười theo TÊN font (NotoSansMono-Bold...): mỗi tên chỉ đọc bytes 1 lần,
// dùng 1 run đại diện để lấy dữ liệu. Lỗi (font không nhúng, không parse
// được) → đánh dấu null, ô sửa dùng stack hệ thống như cũ.
function ensureDocFont(run, path, page) {
const name = run && run.fontName;
if (!name || docFonts.has(name)) return;
const fam = "ff-doc-" + docFontSeq + "-" + name.replace(/[^A-Za-z0-9_-]/g, "");
const p = (async () => {
try {
const b64 = await invoke("edit_font_data", { path, page, index: run.index, password: null });
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const face = new FontFace(fam, bytes.buffer, {
weight: run.fontBold ? "700" : "400",
style: run.fontItalic ? "italic" : "normal",
});
await face.load();
document.fonts.add(face);
docFontFaces.push(face);
docFonts.set(name, fam);
return fam;
} catch (_) {
docFonts.set(name, null);
return null;
}
})();
docFonts.set(name, p);
}
// ---- Tầng đỡ: bộ font ĐÓNG GÓI theo app (Noto, phủ tiếng Việt) ----
// Glyph không có trong subset nhúng (ký tự MỚI gõ vào), font không nhúng hay
// không vá được → rơi xuống font Noto đóng gói — GIỐNG NHAU trên mọi máy,
// không rơi về font hệ điều hành "gần giống".
const appFonts = new Map(); // kind -> true | false | Promise (bền theo app)
function appFontKind(cls, bold, italic) {
if (cls === "mono") return "mono-" + (bold ? "bold" : "regular"); // mono không có italic
const style = bold && italic ? "bolditalic" : bold ? "bold" : italic ? "italic" : "regular";
return cls + "-" + style;
}
// Phân loại serif/sans/mono theo stack CSS đã suy cho family của run.
function fontClassOf(family) {
const stack = cssFontStack(family).toLowerCase();
if (stack.endsWith("monospace")) return "mono";
if (/[^-]serif$/.test(stack)) return "serif";
return "sans";
}
function ensureAppFont(cls, bold, italic) {
const kind = appFontKind(cls, !!bold, !!italic);
if (appFonts.has(kind)) return;
const p = (async () => {
try {
const b64 = await invoke("app_font_data", { kind });
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const face = new FontFace("ff-app-" + cls, bytes.buffer, {
weight: bold ? "700" : "400",
style: cls !== "mono" && italic ? "italic" : "normal",
});
await face.load();
document.fonts.add(face);
appFonts.set(kind, true);
return true;
} catch (_) {
appFonts.set(kind, false); // bản build không kèm fonts/ → stack cũ
return false;
}
})();
appFonts.set(kind, p);
}
// Stack font cho 1 run trong ô sửa: font NHÚNG của tài liệu đứng đầu → font
// Noto đóng gói theo app → stack hệ thống (lưới cuối cùng).
function editFontStack(run, fallbackFamily) {
const base = cssFontStack(fallbackFamily);
const mid = '"ff-app-' + fontClassOf(fallbackFamily) + '", ';
const fam = run && run.fontName ? docFonts.get(run.fontName) : null;
const head = typeof fam === "string" && fam ? '"' + fam + '", ' : "";
return head + mid + base;
}
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)),
},
};
}
// Khoá style của 1 run (gom span trong ô sửa + so trùng style).
function styleKeyOf(r) {
return [
(r.fontFamily || "").toLowerCase(),
!!r.fontBold,
!!r.fontItalic,
Math.round(Math.abs(r.fontSize || 12) * 10),
(r.color || [0, 0, 0]).slice(0, 3).join(","),
].join("|");
}
// Nhóm các run LIỀN KỀ cùng style của 1 dòng thành đoạn văn bản (giữ logic
// chèn khoảng trắng theo khoảng hở như composeLineText) — để dòng lẫn chữ
// thường + đậm hiện đúng từng phần trong ô sửa.
function styleGroupsOfLine(runs) {
const groups = [];
let prev = null;
for (const r of runs) {
const t = r.text || "";
if (!t.trim()) continue;
let sep = "";
if (prev && groups.length) {
const gap = r.rect.left - prev.rect.right;
const em = Math.abs(r.fontSize || 12) * 0.28;
const prevText = groups[groups.length - 1].text;
if (gap > em && !prevText.endsWith(" ") && !t.startsWith(" ")) sep = " ";
}
const key = styleKeyOf(r);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.text += sep + t;
} else {
if (last && sep) last.text += " ";
groups.push({ key, rep: r, text: t });
}
prev = r;
}
return groups;
}
// 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 divFs = Math.max(6, (l.fs || 12) * s);
div.style.fontSize = divFs + "px";
div.style.lineHeight = perLineAdvances[i] + "px";
div.style.fontFamily = editFontStack(rep, 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);
// MỖI NHÓM STYLE = 1 span riêng: dòng lẫn chữ thường + đậm/mono hiện
// đúng từng phần; dataset.styleRun = run mang style (commit giữ style).
const groups = styleGroupsOfLine(l.runs);
if (!groups.length) {
div.innerHTML = "<br>";
} else {
for (const g of groups) {
const span = document.createElement("span");
span.textContent = g.text;
span.dataset.styleRun = String(g.rep.index);
span.style.fontFamily = editFontStack(g.rep, g.rep.fontFamily || o.fontFamily);
span.style.fontWeight = g.rep.fontBold ? "bold" : "normal";
span.style.fontStyle = g.rep.fontItalic ? "italic" : "normal";
span.style.fontSize = Math.max(6, Math.abs(g.rep.fontSize || l.fs || 12) * s) + "px";
if (g.rep.color) span.style.color = rgbCss(g.rep.color);
div.appendChild(span);
}
}
// 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: editStageWidth(state.pages[state.editPage]),
});
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;
};
// \u0110\u1ecdc theo \u0110O\u1ea0N STYLE: m\u1ed7i d\u00f2ng = [{text, style: index run mang style}].
// Text node tr\u1ea7n (g\u00f5 \u1edf m\u00e9p d\u00f2ng) m\u01b0\u1ee3n style c\u1ee7a span k\u1ec1 b\u00ean.
const readRich = () => {
const lines = [];
for (const div of ce.querySelectorAll(".edit-line")) {
const segs = [];
for (const node of div.childNodes) {
const txt = (node.textContent || "").replace(/\u00a0/g, " ");
if (!txt || node.nodeName === "BR") continue;
let si = null;
if (node.nodeType === 1 && node.dataset && node.dataset.styleRun) {
si = Number(node.dataset.styleRun);
} else {
const pick = (sib) =>
sib && sib.nodeType === 1 && sib.dataset && sib.dataset.styleRun
? Number(sib.dataset.styleRun)
: null;
const p = pick(node.previousSibling);
si = p != null ? p : pick(node.nextSibling);
}
if (segs.length && segs[segs.length - 1].style === si) segs[segs.length - 1].text += txt;
else segs.push({ text: txt, style: si });
}
const known = segs.find((g) => g.style != null);
for (const g of segs) {
if (g.style == null) g.style = known ? known.style : allRuns[0].index;
}
lines.push(segs);
}
return lines;
};
let done = false;
const commit = (save) => {
if (done) return;
done = true;
const text = readText();
const rich = readRich();
ce.remove();
ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; });
const changed = save && text.trim() && text !== original;
// LUÔN trả lại ảnh trang gốc ngay (đang hiện bản ẩn run sửa): trong lúc
// engine áp thay đổi (file phức tạp mất vài giây) người dùng thấy chữ CŨ
// thay vì khoảng trống — hết cảm giác "chữ biến mất".
if (bgSwapped) img.src = prevSrc;
if (changed) {
$("editHint").textContent = "Đang áp dụng thay đổi…";
// Khối có TỪ 2 STYLE trở lên (thường + đậm/mono...) → gửi kèm đoạn
// style để engine giữ đúng font/màu từng phần; 1 style → đường cũ
// (ưu tiên dùng lại chính font object gốc).
const styleCount = new Set(
rich.flatMap((line) => line.map((g) => styleKeyOf(state.editObjects.find((x) => x.index === g.style) || {})))
).size;
stageEditOp({
op: "reflowText",
indices: allRuns.map((r) => r.index),
text,
richLines: styleCount > 1 ? rich : null,
});
} 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) {
const k = Math.max(0.3, Math.min(3, ratio));
divs[i].style.fontSize = f.fs * k + "px";
// Dòng nhiều style: span có cỡ px riêng → scale theo cùng hệ số.
divs[i].querySelectorAll("span[data-style-run]").forEach((sp) => {
const cur = parseFloat(sp.style.fontSize) || f.fs;
sp.style.fontSize = cur * k + "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. Text có
// thể nằm trong SPAN (dòng nhiều style) → tìm text node đầu tiên theo sâu.
function firstTextRect(div) {
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
let tn = walker.nextNode();
while (tn && !(tn.textContent || "").trim()) tn = walker.nextNode();
if (!tn) 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;
// Đổi thuộc tính tại chỗ cho run TRONG form chưa hỗ trợ (engine chỉ phẫu
// thuật được sửa/xoá nội dung) — đúp chuột để sửa cả đoạn thì được.
if (runs.some((r) => r.nested)) {
$("editHint").textContent =
"Đổi font/cỡ/màu cho chữ trong khối form chưa hỗ trợ — đúp chuột để sửa nội dung, hoặc xoá.";
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/xoá 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)) {
$("editHint").textContent =
"Khối này nằm trong form — đúp chuột để sửa nội dung; di chuyển/resize chưa hỗ trợ.";
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 });
}
// Huỷ MỌI thay đổi chưa lưu: quay về file gốc, xoá sạch undo/redo + file tạm.
function discardEdits() {
if (state.editBase === state.path) return; // chưa có gì để huỷ
const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove();
cleanupEditTemps(); // editBase cũ nằm trong editTemps — xoá luôn
state.editBase = state.path;
state.editUndo = [];
state.editRedo = [];
state.editSel = null;
selectEditObject(-1);
loadEditPage();
$("status").textContent = "Đã huỷ mọi thay đổi nội dung — trở về file gốc.";
}
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", `<p class="muted">Tài liệu chưa có field form nào. Hãy dùng “➕ Thêm field”.</p>
<div class="foot"><button id="ffClose" class="primary">Đóng</button></div>`)
.querySelector("#ffClose").addEventListener("click", closeModal);
return;
}
const rows = fields
.map((f, i) => {
const label = `${escapeHtml(f.name)} <span class="muted">(${f.kind}${f.pageIndex != null ? `, trang ${f.pageIndex + 1}` : ""})</span>`;
let input;
if (f.kind === "checkbox" || f.kind === "radio") {
const on = f.value && f.value !== "Off";
input = `<input type="checkbox" id="ffv${i}" ${on ? "checked" : ""} ${f.readOnly ? "disabled" : ""}>`;
} else if ((f.kind === "combo" || f.kind === "list") && f.options.length) {
const opts = f.options
.map((o) => `<option ${o === f.value ? "selected" : ""}>${escapeHtml(o)}</option>`)
.join("");
input = `<select id="ffv${i}" ${f.readOnly ? "disabled" : ""}><option value=""></option>${opts}</select>`;
} else {
input = `<input type="text" id="ffv${i}" value="${escapeHtml(f.value || "")}" ${f.readOnly ? "disabled" : ""}>`;
}
return `<tr><td>${label}</td><td>${input}</td></tr>`;
})
.join("");
const box = openModal("Điền form", `
<table class="form-table"><tbody>${rows}</tbody></table>
<div class="err" id="ffErr"></div>
<div class="foot"><button id="ffCancel">Huỷ</button><button id="ffOk" class="primary">Lưu &amp; áp dụng…</button></div>
`);
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", `
<label>Tên field</label>
<input type="text" id="nfName" placeholder="hoTen">
<label>Loại</label>
<select id="nfKind">
<option value="text">Text (ô nhập chữ)</option>
<option value="checkbox">Checkbox</option>
<option value="combo">Combo box (chọn 1)</option>
</select>
<div class="row">
<div><label>Trang</label><input type="number" id="nfPage" value="1" min="1"></div>
<div><label>Cỡ (rộng×cao pt)</label><input type="text" id="nfSize" value="200x20"></div>
</div>
<div class="row">
<div><label>Vị trí X (pt)</label><input type="number" id="nfX" value="80"></div>
<div><label>Vị trí Y từ đáy (pt)</label><input type="number" id="nfY" value="700"></div>
</div>
<label id="nfOptLabel" class="hidden">Lựa chọn (cách nhau dấu phẩy)</label>
<input type="text" id="nfOpts" class="hidden" placeholder="Nam, Nữ, Khác">
<div class="err" id="nfErr"></div>
<div class="foot"><button id="nfCancel">Huỷ</button><button id="nfOk" class="primary">Tạo &amp; lưu…</button></div>
`);
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)", `
<label>Mật khẩu mở file (user password)</label>
<input type="password" id="secPw1" autocomplete="new-password">
<label>Nhập lại mật khẩu</label>
<input type="password" id="secPw2" autocomplete="new-password">
<label>Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)</label>
<input type="password" id="secPwOwner" autocomplete="new-password">
<label>Quyền hạn khi mở bằng mật khẩu user</label>
<div class="row">
<label><input type="checkbox" id="secPermPrint" checked> In</label>
<label><input type="checkbox" id="secPermModify" checked> Sửa nội dung</label>
</div>
<div class="row">
<label><input type="checkbox" id="secPermExtract" checked> Sao chép text/ảnh</label>
<label><input type="checkbox" id="secPermAnnotate" checked> Chú thích/điền form</label>
</div>
<div class="err" id="secEncErr"></div>
<div class="foot"><button id="secEncCancel">Huỷ</button><button id="secEncOk" class="primary">Mã hoá…</button></div>
`);
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", `
<p class="muted">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á.</p>
<label>Mật khẩu hiện tại</label>
<input type="password" id="secDecPw" autocomplete="current-password">
<div class="err" id="secDecErr"></div>
<div class="foot"><button id="secDecCancel">Huỷ</button><button id="secDecOk" class="primary">Chọn file &amp; gỡ…</button></div>
`);
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ý", `
<p class="muted">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ố.</p>
<label>Tên hiển thị trong chữ ký</label>
<input type="text" id="idCn" placeholder="Nguyễn Văn A">
<div class="err" id="idErr"></div>
<div class="foot"><button id="idCancel">Huỷ</button><button id="idOk" class="primary">Tạo &amp; lưu…</button></div>
`);
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", `
<label>Tên người ký (hiện trong chữ ký)</label>
<input type="text" id="sgName" placeholder="Nguyễn Văn A">
<label>Lý do ký</label>
<input type="text" id="sgReason" value="Tôi đồng ý với nội dung tài liệu">
<label>Digital ID (.pem)</label>
<div class="row">
<input type="text" id="sgId" readonly placeholder="Chưa chọn — bấm Chọn…">
<button id="sgPickId">Chọn…</button>
</div>
<div class="err" id="sgErr"></div>
<div class="foot"><button id="sgCancel">Huỷ</button><button id="sgOk" class="primary">Ký &amp; lưu…</button></div>
`);
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
? '<span class="sig-ok">✓ Hợp lệ</span>'
: '<span class="sig-bad">✗ Không hợp lệ</span>';
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 `<tr><td>${i + 1}</td><td>${(c.signer || "").replace(/</g, "&lt;")}</td><td>${badge}</td>
<td class="muted">${detail.join("; ")}</td></tr>`;
})
.join("")
: `<tr><td colspan="4" class="muted">Tài liệu chưa có chữ ký số nào.</td></tr>`;
const box = openModal("Kết quả kiểm tra chữ ký", `
<table class="sig-table">
<thead><tr><th>#</th><th>Người ký</th><th>Trạng thái</th><th>Ghi chú</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="foot"><button id="sigClose" class="primary">Đóng</button></div>
`);
box.querySelector("#sigClose").addEventListener("click", closeModal);
}
// ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile);
// Hint bị cắt bớt bằng CSS (1 dòng cố định) → hover xem đủ qua title.
new MutationObserver(() => {
$("editHint").title = $("editHint").textContent;
}).observe($("editHint"), { childList: true, characterData: true, subtree: true });
// 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.editMode ? state.editZoom : state.zoom) * 1.25));
$("zoomOut").addEventListener("click", () => setZoom((state.editMode ? state.editZoom : state.zoom) / 1.25));
// Ctrl + lăn chuột để zoom trong chế độ Sửa nội dung (viewer đã có sẵn).
$("editStage").addEventListener(
"wheel",
(e) => {
if (!e.ctrlKey) return;
e.preventDefault();
setEditZoom(state.editZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
},
{ passive: false }
);
$("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);
$("edDiscard").addEventListener("click", discardEdits);
$("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);
});
// Đúp vào trang READ-ONLY trong cột: trang đó thành active + mở ô sửa ngay
// tại điểm đúp (qua editPendingPoint — cùng cơ chế với đúp từ viewer thường).
$("editPagesCol").addEventListener("dblclick", (e) => {
if (state.editArm) return;
const slot = e.target.closest && e.target.closest(".edit-slot");
if (!slot || slot.contains(editWrapEl)) return; // trang active: overlay lo
const idx = Number(slot.dataset.page);
const p = state.pages[idx];
const r = slot.getBoundingClientRect();
const sc = r.width / p.widthPt;
state.editPendingPoint = {
x: (e.clientX - r.left) / sc,
y: p.heightPt - (e.clientY - r.top) / sc,
};
switchEditPage(idx, "nearest");
});
// Cuộn trong chế độ sửa: ô chỉ số trang bám theo trang đang nhìn; khi không
// có ô sửa mở, trang giữa khung nhìn tự thành trang active (nav ▲▼, toolbar
// và phím tắt luôn tác động đúng trang người dùng đang xem).
let editScrollTimer = null;
$("editStage").addEventListener("scroll", () => {
if (!state.editMode || !(state.editSlots || []).length) return;
clearTimeout(editScrollTimer);
editScrollTimer = setTimeout(() => {
const st = $("editStage").getBoundingClientRect();
const mid = st.top + ($("editStage").clientHeight * 0.45);
let best = state.editPage;
let bestD = Infinity;
state.editSlots.forEach((slot, i) => {
const r = slot.getBoundingClientRect();
const d = Math.abs((r.top + r.bottom) / 2 - mid);
if (d < bestD) { bestD = d; best = i; }
});
$("pageInput").value = best + 1;
if (best !== state.editPage && !$("editOverlay").querySelector(".edit-inline")) {
switchEditPage(best, null); // người dùng đang tự cuộn — không cuộn hộ
}
}, 200);
});
// 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);