const { invoke } = window.__TAURI__.core;
const $ = (id) => document.getElementById(id);
const PT_PER_PX = 96 / 72; // 1pt = 1.333px ở zoom 100%
const DPR = window.devicePixelRatio || 1;
const state = {
path: null,
pages: [], // [{index,widthPt,heightPt}]
zoom: 1.0, // 1.0 = 100%
slots: [], // phần tử DOM theo trang
visible: new Set(), // trang đang trong vùng nhìn
current: 0, // trang hiện tại (trên cùng vùng nhìn)
hits: [], // kết quả tìm kiếm
hitIdx: -1,
textLayers: {}, // cache hộp ký tự theo trang
tool: null, // công cụ chú thích đang chọn
color: [255, 230, 0], // màu chú thích (RGB)
annotSpecs: [], // chú thích chưa lưu
selectedId: null, // annotation đang chọn
recentColors: [], // màu dùng gần đây
undoStack: [], // snapshot {annotSpecs,pagePlan} trước mỗi hành động (Ctrl+Z)
redoStack: [],
pagePlan: [], // [{kind:'existing'|'blank', source, srcIndex, widthPt, heightPt, rotationDelta, crop}]
organizeMode: false,
orgSelected: new Set(), // index trong pagePlan đang chọn (chế độ Tổ chức trang)
orgAnchor: null, // index neo cho Shift-click chọn dải
orgThumbs: new Map(), // cache dataURL thumbnail theo "source#srcIndex" (tránh render lại khi chọn/xoay)
// ----- Phase 4: Sửa nội dung -----
editMode: false,
editPage: 0, // trang đang sửa
editBase: null, // file làm việc hiện tại (mỗi op materialize ra file tạm mới)
editUndo: [], // stack đường dẫn editBase trước mỗi op
editRedo: [],
editObjects: [], // ObjectInfo của editPage (đọc lại từ editBase sau mỗi op)
editSel: null, // index object đại diện đang chọn
editSelRuns: [], // mọi run thuộc khung đang chọn (dòng text = nhiều run)
editScale: 1, // px/pt của ảnh stage
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường)
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
secMode: false, // thanh Bảo mật (Phase 5) đang mở
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
formMode: false, // thanh Form (Phase 6) đang mở
formFields: [], // field đọc từ tài liệu hiện tại
convMode: false, // thanh Chuyển đổi (Phase 7) đang mở
};
const UNDO_LIMIT = 50;
let annotIdSeq = 1;
const PRESET_COLORS = [
[255, 230, 0], [255, 145, 0], [255, 0, 0], [233, 30, 99],
[156, 39, 176], [63, 81, 181], [33, 150, 243], [0, 188, 212],
[0, 150, 136], [76, 175, 80], [0, 0, 0], [120, 120, 120],
];
function displaySize(p) {
return {
w: Math.round(p.widthPt * PT_PER_PX * state.zoom),
h: Math.round(p.heightPt * PT_PER_PX * state.zoom),
};
}
// ---------- Khởi tạo ----------
function shortName(path) {
return path.split(/[\\/]/).pop();
}
async function loadDocument(path) {
try {
state.path = path;
state.textLayers = {};
state.hits = [];
state.hitIdx = -1;
state.current = 0;
state.annotSpecs = [];
state.undoStack = [];
state.redoStack = [];
state.redactMarks = [];
updateRedactButtons();
state.formFields = [];
updateUndoRedoButtons();
setTool(null);
updateAnnotCount();
buildComments();
$("searchBox").value = "";
$("searchCount").textContent = "—";
let meta;
try {
meta = await invoke("open_document", { path });
} catch (e) {
// File hỏng (xref/trailer sai) — tự sửa qua QPDF rồi mở lại (Phase 3).
const usable = await invoke("ensure_openable", { path, password: null });
if (usable === path) throw e;
state.path = usable;
meta = await invoke("open_document", { path: usable });
}
state.pages = meta.pages;
buildPages();
buildThumbnails();
buildOutline(meta.outline);
$("status").textContent = `${meta.pageCount} trang · ${shortName(path)}`;
updatePageTotal();
updateCurrentPage();
updateZoomLabel();
// Khởi tạo plan mặc định ngay (tất cả trang theo thứ tự gốc).
// Load plan thực tế ở background để không block UI khi mở file.
state.pagePlan = state.pages.map((p) => ({ page: p.index, rotate: 0 }));
invoke("organize_identity_plan", { path: state.path, password: null })
.then((plan) => {
state.pagePlan = plan;
})
.catch(() => {});
state.orgSelected = new Set();
state.orgAnchor = null;
state.orgThumbs = new Map();
if (state.organizeMode) buildOrganizeGrid();
} catch (e) {
$("status").textContent = "Lỗi mở tài liệu: " + e;
}
}
async function boot() {
// Nếu app được mở qua "Open with FoFreeXit" / double-click file PDF trong
// Explorer, Windows truyền đường dẫn file qua command-line — ưu tiên mở nó.
const fromExplorer = await invoke("initial_file");
loadDocument(fromExplorer || (await invoke("default_pdf")));
}
async function openFile() {
try {
const path = await invoke("pick_pdf");
if (path) loadDocument(path);
} catch (e) {
$("status").textContent = "Lỗi chọn file: " + e;
}
}
let pageObserver;
function buildPages() {
const container = $("pages");
container.innerHTML = "";
state.slots = [];
state.visible.clear();
for (const p of state.pages) {
const slot = document.createElement("div");
slot.className = "page-slot";
slot.dataset.index = p.index;
const { w, h } = displaySize(p);
slot.style.width = w + "px";
slot.style.height = h + "px";
const ph = document.createElement("div");
ph.className = "ph";
ph.textContent = `Trang ${p.index + 1}`;
slot.appendChild(ph);
const overlay = document.createElement("div");
overlay.className = "overlay";
slot.appendChild(overlay);
const annotlayer = document.createElement("div");
annotlayer.className = "annotlayer";
slot.appendChild(annotlayer);
container.appendChild(slot);
state.slots[p.index] = slot;
}
if (pageObserver) pageObserver.disconnect();
pageObserver = new IntersectionObserver(onIntersect, {
root: $("viewport"),
rootMargin: "500px 0px",
threshold: 0.01,
});
for (const slot of state.slots) pageObserver.observe(slot);
$("viewport").scrollTop = 0;
}
function onIntersect(entries) {
for (const e of entries) {
const idx = Number(e.target.dataset.index);
if (e.isIntersecting) {
state.visible.add(idx);
renderSlot(idx);
} else {
state.visible.delete(idx);
}
}
updateCurrentPage();
}
function zoomKey() {
return state.zoom.toFixed(3);
}
async function renderSlot(idx) {
const slot = state.slots[idx];
if (!slot) return;
if (slot.dataset.renderedZoom === zoomKey()) return; // đã render ở zoom này
slot.dataset.renderedZoom = zoomKey();
const p = state.pages[idx];
const renderWidth = Math.round(p.widthPt * PT_PER_PX * state.zoom * DPR);
try {
const dataUrl = await invoke("render_page", {
path: state.path,
page: idx,
width: renderWidth,
});
let img = slot.querySelector("img");
if (!img) {
img = document.createElement("img");
slot.insertBefore(img, slot.firstChild);
}
img.src = dataUrl;
const ph = slot.querySelector(".ph");
if (ph) ph.remove();
buildTextLayer(idx);
drawAnnotsForPage(idx);
} catch (e) {
slot.dataset.renderedZoom = ""; // cho phép thử lại
$("status").textContent = "Lỗi render trang " + (idx + 1) + ": " + e;
}
}
// Dựng lớp text trong suốt cho phép chọn & copy text trên ảnh trang.
async function buildTextLayer(idx) {
const slot = state.slots[idx];
if (!slot) return;
let layer = slot.querySelector(".textlayer");
if (!layer) {
layer = document.createElement("div");
layer.className = "textlayer";
slot.appendChild(layer);
}
let boxes = state.textLayers[idx];
if (!boxes) {
try {
boxes = await invoke("page_text_layer", { path: state.path, page: idx });
state.textLayers[idx] = boxes;
} catch {
return;
}
}
const p = state.pages[idx];
const scale = PT_PER_PX * state.zoom;
layer.innerHTML = "";
const frag = document.createDocumentFragment();
// Gom ký tự thành "từ" (mỗi từ = 1 span) để double-click chọn cả từ,
// kéo/Shift+Click chọn dải, và copy giữ khoảng trắng (chèn text node " ").
let word = null;
const flush = () => {
if (word && word.text) {
const w = (word.right - word.left) * scale;
const h = (word.top - word.bottom) * scale;
const span = document.createElement("span");
span.textContent = word.text;
span.style.left = word.left * scale + "px";
span.style.top = (p.heightPt - word.top) * scale + "px";
span.style.width = Math.max(w, 1) + "px";
span.style.height = h + "px";
span.style.fontSize = h * 0.82 + "px";
span.style.lineHeight = h + "px";
frag.appendChild(span);
}
word = null;
};
for (const b of boxes) {
const isWs = !b.ch || /\s/.test(b.ch);
if (isWs) {
flush();
frag.appendChild(document.createTextNode(b.ch && /\n/.test(b.ch) ? "\n" : " "));
continue;
}
const h = b.top - b.bottom;
if (h <= 0) continue;
if (word) {
const sameLine = Math.abs(b.bottom - word.bottom) <= 0.6 * (word.top - word.bottom);
const gap = b.left - word.right;
if (!sameLine || gap > 0.3 * (word.top - word.bottom)) {
flush();
frag.appendChild(document.createTextNode(" "));
}
}
if (!word) {
word = { text: b.ch, left: b.left, right: b.right, top: b.top, bottom: b.bottom };
} else {
word.text += b.ch;
word.left = Math.min(word.left, b.left);
word.right = Math.max(word.right, b.right);
word.top = Math.max(word.top, b.top);
word.bottom = Math.min(word.bottom, b.bottom);
}
}
flush();
layer.appendChild(frag);
}
function updateCurrentPage() {
const vp = $("viewport");
const probe = vp.scrollTop + vp.clientHeight * 0.35;
let cur = 0;
for (const slot of state.slots) {
if (!slot) continue;
if (slot.offsetTop <= probe) cur = Number(slot.dataset.index);
else break;
}
if (cur !== state.current) {
state.current = cur;
if (document.activeElement !== $("pageInput")) $("pageInput").value = cur + 1;
document.querySelectorAll(".thumb").forEach((t) => {
t.classList.toggle("active", Number(t.dataset.index) === cur);
});
}
}
function updatePageTotal() {
$("pageTotal").textContent = state.pages.length || "—";
$("pageInput").value = state.pages.length ? state.current + 1 : "";
}
function goToPageInput() {
const n = parseInt($("pageInput").value, 10);
if (!Number.isFinite(n)) { $("pageInput").value = state.current + 1; return; }
const idx = Math.max(0, Math.min(state.pages.length - 1, n - 1));
goToPage(idx);
$("pageInput").value = idx + 1;
}
function gotoPrevPage() { goToPage(Math.max(0, state.current - 1)); }
function gotoNextPage() { goToPage(Math.min(state.pages.length - 1, state.current + 1)); }
// ---------- Thumbnails ----------
let thumbObserver;
const THUMB_W = 130;
function buildThumbnails() {
const box = $("thumbs");
box.innerHTML = "";
if (thumbObserver) thumbObserver.disconnect();
// Lazy: chỉ render thumbnail khi cuộn tới (quan trọng với file 1000+ trang).
thumbObserver = new IntersectionObserver(onThumbIntersect, {
root: box,
rootMargin: "400px 0px",
threshold: 0.01,
});
for (const p of state.pages) {
const el = document.createElement("div");
el.className = "thumb";
el.dataset.index = p.index;
// Giữ chỗ đúng tỉ lệ để placeholder có chiều cao (observer hoạt động đúng).
const thumbH = Math.round(THUMB_W * (p.heightPt / p.widthPt));
el.style.minHeight = thumbH + 18 + "px";
el.innerHTML = `
${p.index + 1}
`;
el.addEventListener("click", () => goToPage(p.index));
box.appendChild(el);
thumbObserver.observe(el);
}
}
function onThumbIntersect(entries) {
for (const e of entries) {
if (!e.isIntersecting) continue;
const el = e.target;
if (el.dataset.loaded) continue;
el.dataset.loaded = "1";
const idx = Number(el.dataset.index);
invoke("render_page", { path: state.path, page: idx, width: 150 })
.then((url) => {
const img = el.querySelector("img");
img.src = url;
img.style.height = ""; // trả lại auto theo tỉ lệ
el.style.minHeight = "";
})
.catch(() => { el.dataset.loaded = ""; });
}
}
// ---------- Outline ----------
function buildOutline(items) {
const box = $("outline");
box.innerHTML = "";
if (!items.length) {
box.innerHTML = `Không có outline
`;
return;
}
for (const it of items) {
const el = document.createElement("div");
el.className = "oitem";
el.style.paddingLeft = 6 + it.level * 14 + "px";
el.textContent = it.title || "(không tiêu đề)";
if (it.pageIndex != null) {
el.addEventListener("click", () => goToPage(it.pageIndex));
} else {
el.style.color = "#777";
}
box.appendChild(el);
}
}
// ---------- Điều hướng ----------
function goToPage(idx) {
const slot = state.slots[idx];
if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ---------- Zoom ----------
function setZoom(z) {
finishEditing();
closeNotePopup();
closeColorPopover();
state.zoom = Math.max(0.25, Math.min(5, z));
// Phản hồi tức thì & mượt: chỉ đổi kích thước slot → ảnh sẵn có co giãn theo.
for (const p of state.pages) {
const slot = state.slots[p.index];
if (!slot) continue;
const { w, h } = displaySize(p);
slot.style.width = w + "px";
slot.style.height = h + "px";
}
// Overlay/annotation rẻ → vẽ lại ngay để bám theo.
drawHighlightsForVisible();
for (const idx of state.visible) drawAnnotsForPage(idx);
updateZoomLabel();
// Render NÉT bằng PDFium chỉ sau khi ngừng zoom (tránh flood gây lag/crash).
scheduleSharpRerender();
}
let sharpTimer = null;
function scheduleSharpRerender() {
clearTimeout(sharpTimer);
sharpTimer = setTimeout(() => {
for (const idx of state.visible) {
const slot = state.slots[idx];
if (slot) {
slot.dataset.renderedZoom = ""; // buộc render lại ở zoom mới
clearOverlay(slot);
}
renderSlot(idx);
}
drawHighlightsForVisible();
for (const idx of state.visible) drawAnnotsForPage(idx);
}, 180);
}
const ZOOM_PRESETS = [50, 75, 100, 125, 150, 200, 300, 400];
function buildZoomSelect() {
const sel = $("zoomSelect");
sel.innerHTML = "";
for (const pct of ZOOM_PRESETS) {
const o = document.createElement("option");
o.value = pct; o.textContent = pct + "%";
sel.appendChild(o);
}
}
function updateZoomLabel() {
const sel = $("zoomSelect");
const pct = Math.round(state.zoom * 100);
let custom = sel.querySelector('option[data-custom="1"]');
const matchesPreset = ZOOM_PRESETS.includes(pct);
if (matchesPreset) {
if (custom) custom.remove();
sel.value = String(pct);
} else {
if (!custom) {
custom = document.createElement("option");
custom.dataset.custom = "1";
sel.appendChild(custom);
}
custom.value = String(pct);
custom.textContent = pct + "%";
sel.value = String(pct);
}
}
// Zoom giữ nguyên điểm dưới con trỏ chuột (Ctrl + lăn chuột).
function zoomAtPoint(newZoom, clientX, clientY) {
const vp = $("viewport");
const rect = vp.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const cx = vp.scrollLeft + px;
const cy = vp.scrollTop + py;
const old = state.zoom;
setZoom(newZoom);
const f = state.zoom / old;
vp.scrollLeft = cx * f - px;
vp.scrollTop = cy * f - py;
}
function fitWidth() {
if (!state.pages.length) return;
const avail = $("viewport").clientWidth - 48;
const p = state.pages[state.current] || state.pages[0];
setZoom(avail / (p.widthPt * PT_PER_PX));
}
function fitPage() {
if (!state.pages.length) return;
const vp = $("viewport");
const availW = vp.clientWidth - 48;
const availH = vp.clientHeight - 48;
const p = state.pages[state.current] || state.pages[0];
const zw = availW / (p.widthPt * PT_PER_PX);
const zh = availH / (p.heightPt * PT_PER_PX);
setZoom(Math.min(zw, zh));
}
// ---------- Tìm kiếm ----------
let searchTimer;
async function runSearch() {
const q = $("searchBox").value.trim();
clearAllHighlights();
state.hits = [];
state.hitIdx = -1;
if (!q) { $("searchCount").textContent = "—"; return; }
try {
const hits = await invoke("search_document", {
path: state.path,
query: q,
caseSensitive: $("searchCase").checked,
});
state.hits = hits;
$("searchCount").textContent = hits.length ? `0/${hits.length}` : "0";
if (hits.length) gotoHit(0);
drawHighlightsForVisible();
} catch (e) {
$("searchCount").textContent = "lỗi";
$("status").textContent = "Lỗi tìm kiếm: " + e;
}
}
function gotoHit(i) {
if (!state.hits.length) return;
state.hitIdx = (i + state.hits.length) % state.hits.length;
const hit = state.hits[state.hitIdx];
$("searchCount").textContent = `${state.hitIdx + 1}/${state.hits.length}`;
goToPage(hit.pageIndex);
// chờ cuộn rồi vẽ highlight
setTimeout(() => { drawHighlightsForVisible(); }, 120);
}
function clearOverlay(slot) {
const ov = slot.querySelector(".overlay");
if (ov) ov.innerHTML = "";
}
function clearAllHighlights() {
for (const slot of state.slots) if (slot) clearOverlay(slot);
}
function drawHighlightsForVisible() {
clearAllHighlights();
for (const idx of state.visible) drawHighlightsForPage(idx);
// luôn vẽ trang chứa hit hiện tại
if (state.hitIdx >= 0) drawHighlightsForPage(state.hits[state.hitIdx].pageIndex);
}
function drawHighlightsForPage(pageIdx) {
const slot = state.slots[pageIdx];
if (!slot) return;
const p = state.pages[pageIdx];
const ov = slot.querySelector(".overlay");
if (!ov) return;
const scale = PT_PER_PX * state.zoom;
state.hits.forEach((h, i) => {
if (h.pageIndex !== pageIdx || !h.rect) return;
const r = h.rect;
const div = document.createElement("div");
div.className = "hl" + (i === state.hitIdx ? " current" : "");
div.style.left = r.left * scale + "px";
div.style.top = (p.heightPt - r.top) * scale + "px";
div.style.width = (r.right - r.left) * scale + "px";
div.style.height = (r.top - r.bottom) * scale + "px";
ov.appendChild(div);
});
}
// ---------- Chú thích (annotations) ----------
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbCss(c) { return `rgb(${c[0]},${c[1]},${c[2]})`; }
function escapeHtml(s) {
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
}
// Highlight/Underline/Strikeout bám theo TEXT đã chọn (như Foxit), không phải
// 1 rectangle tự do — khác với Square/FreeText/Note (vẽ/đặt tự do trên trang).
function isTextMarkupTool(tool) {
return tool === "highlight" || tool === "underline" || tool === "strikeout";
}
function setTool(tool) {
state.tool = state.tool === tool ? null : tool;
document.querySelectorAll(".atool").forEach((b) =>
b.classList.toggle("active", b.dataset.tool === state.tool)
);
document.body.classList.toggle("tool-active", !!state.tool);
document.body.classList.toggle("tool-mark", isTextMarkupTool(state.tool));
$("annotHint").textContent = state.tool
? state.tool === "note"
? "Bấm lên trang để đặt ghi chú"
: state.tool === "redact"
? "Kéo chuột quét vùng cần bôi đen — nội dung sẽ bị XOÁ THẬT khi Áp dụng"
: isTextMarkupTool(state.tool)
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
: "Kéo chuột trên trang để vẽ"
: "";
}
function updateAnnotCount() {
$("annotCount").textContent = state.annotSpecs.length;
$("saveAnnots").disabled = state.annotSpecs.length === 0;
}
// ===== Undo/Redo TOÀN CỤC (Ctrl+Z / Ctrl+Y) =====
// Snapshot {annotSpecs, pagePlan} trước mỗi hành động thay đổi (tạo/xoá/sửa
// chú thích, chèn/xoá/xoay/đảo/crop trang...) — đơn giản & an toàn cho quy mô
// dữ liệu của 1 tài liệu, không cần theo dõi diff từng trường. 1 stack DUY
// NHẤT cho cả Annotate và Organize → Ctrl+Z hoạt động xuyên cả 2 chế độ.
function snapshot() {
return JSON.parse(JSON.stringify({ annotSpecs: state.annotSpecs, pagePlan: state.pagePlan }));
}
function pushUndo() {
state.undoStack.push(snapshot());
if (state.undoStack.length > UNDO_LIMIT) state.undoStack.shift();
state.redoStack = [];
updateUndoRedoButtons();
}
function updateUndoRedoButtons() {
$("undoBtn").disabled = state.undoStack.length === 0;
$("redoBtn").disabled = state.redoStack.length === 0;
}
function redrawAllAnnotPages() {
for (const slot of state.slots) {
if (slot) drawAnnotsForPage(Number(slot.dataset.index));
}
}
function applySnapshot(snap) {
finishEditing();
closeNotePopup();
state.annotSpecs = snap.annotSpecs;
state.pagePlan = snap.pagePlan;
state.selectedId = null;
state.orgSelected = new Set();
redrawAllAnnotPages();
updateAnnotCount();
updateUndoRedoButtons();
buildComments();
if (state.organizeMode) buildOrganizeGrid();
}
function undo() {
if (!state.undoStack.length) return;
const prev = state.undoStack.pop();
state.redoStack.push(snapshot());
applySnapshot(prev);
}
function redo() {
if (!state.redoStack.length) return;
const next = state.redoStack.pop();
state.undoStack.push(snapshot());
applySnapshot(next);
}
// Vẽ preview toàn bộ chú thích chưa lưu của 1 trang.
function drawAnnotsForPage(idx) {
const slot = state.slots[idx];
if (!slot) return;
const layer = slot.querySelector(".annotlayer");
if (!layer) return;
layer.innerHTML = "";
const p = state.pages[idx];
const scale = PT_PER_PX * state.zoom;
const isMarkup = (k) => k === "highlight" || k === "underline" || k === "strikeout";
for (const s of state.annotSpecs) {
if (s.pageIndex !== idx) continue;
if (editing && editing.id === s.id) continue; // đang sửa thì bỏ qua preview
const col = rgbCss(s.color);
if (isMarkup(s.kind)) {
// Mỗi quad (= 1 dòng text đã chọn) vẽ riêng — không phải 1 khối phủ cả
// khoảng trắng giữa các dòng (đúng như Foxit).
const quads = s.quads && s.quads.length ? s.quads : [{ left: s.left, bottom: s.bottom, right: s.right, top: s.top }];
quads.forEach((q, qi) => {
const left = q.left * scale;
const top = (p.heightPt - q.top) * scale;
const w = (q.right - q.left) * scale;
const h = (q.top - q.bottom) * scale;
const el = document.createElement("div");
el.dataset.id = s.id;
if (s.kind === "highlight") {
el.className = "a-hl a-sel";
el.style.background = `rgba(${s.color[0]},${s.color[1]},${s.color[2]},.4)`;
Object.assign(el.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" });
} else if (s.kind === "underline") {
el.className = "a-line a-sel";
el.style.borderTopColor = col;
Object.assign(el.style, { left: left + "px", top: top + h - 2 + "px", width: w + "px", height: "4px" });
} else {
el.className = "a-line a-sel";
el.style.borderTopColor = col;
Object.assign(el.style, { left: left + "px", top: top + h / 2 + "px", width: w + "px", height: "4px" });
}
el.addEventListener("click", (ev) => { ev.stopPropagation(); selectAnnot(s.id); });
if (s.id === state.selectedId) {
el.classList.add("selected");
if (qi === quads.length - 1) {
const del = document.createElement("div");
del.className = "a-del";
del.textContent = "✕";
del.title = "Xoá (Delete)";
del.addEventListener("click", (ev) => { ev.stopPropagation(); deleteSpec(s.id); });
el.appendChild(del);
}
}
layer.appendChild(el);
});
continue;
}
const left = s.left * scale;
const top = (p.heightPt - s.top) * scale;
const w = (s.right - s.left) * scale;
const h = (s.top - s.bottom) * scale;
const el = document.createElement("div");
el.dataset.id = s.id;
if (s.kind === "square") {
el.className = "a-box a-sel";
el.style.borderColor = col;
Object.assign(el.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" });
} else if (s.kind === "freetext") {
el.className = "a-text a-sel";
el.style.borderColor = col;
el.style.color = col;
el.style.fontSize = (s.fontSize || 14) * scale + "px";
el.style.fontWeight = s.bold ? "bold" : "normal";
el.style.fontStyle = s.italic ? "italic" : "normal";
el.style.textDecoration = s.underline ? "underline" : "none";
el.textContent = s.contents || "Văn bản…";
Object.assign(el.style, { left: left + "px", top: top + "px", width: Math.max(w, 40) + "px", minHeight: h + "px" });
el.addEventListener("dblclick", (ev) => { ev.stopPropagation(); editTextBox(s); });
} else if (s.kind === "note") {
el.className = "a-note a-sel";
el.textContent = "📝";
el.style.background = `rgb(${s.color[0]},${s.color[1]},${s.color[2]})`;
Object.assign(el.style, { left: left + "px", top: top + "px" });
el.addEventListener("click", (ev) => { ev.stopPropagation(); openNotePopup(s); });
}
if (s.kind !== "note") {
el.addEventListener("click", (ev) => { ev.stopPropagation(); selectAnnot(s.id); });
}
if (s.id === state.selectedId) {
el.classList.add("selected");
const del = document.createElement("div");
del.className = "a-del";
del.textContent = "✕";
del.title = "Xoá (Delete)";
del.addEventListener("click", (ev) => { ev.stopPropagation(); deleteSpec(s.id); });
el.appendChild(del);
}
layer.appendChild(el);
}
// Đánh dấu redact (Phase 5) — vẽ cùng layer để sống sót qua mọi lần redraw.
for (const m of state.redactMarks) {
if (m.page !== idx) continue;
const el = document.createElement("div");
el.className = "redact-mark";
Object.assign(el.style, {
left: m.rect.left * scale + "px",
top: (p.heightPt - m.rect.top) * scale + "px",
width: (m.rect.right - m.rect.left) * scale + "px",
height: (m.rect.top - m.rect.bottom) * scale + "px",
});
el.title = "Bấm để bỏ đánh dấu redact";
el.addEventListener("click", (ev) => {
ev.stopPropagation();
const i = state.redactMarks.indexOf(m);
if (i >= 0) state.redactMarks.splice(i, 1);
drawAnnotsForPage(idx);
updateRedactButtons();
});
layer.appendChild(el);
}
}
function selectAnnot(id) {
state.selectedId = id;
finishEditing();
redrawVisibleAnnots();
}
function deselectAnnot() {
if (state.selectedId == null) return;
state.selectedId = null;
redrawVisibleAnnots();
}
function redrawVisibleAnnots() {
for (const idx of state.visible) drawAnnotsForPage(idx);
}
function deleteSpec(id) {
const i = state.annotSpecs.findIndex((s) => s.id === id);
if (i < 0) return;
pushUndo();
const pg = state.annotSpecs[i].pageIndex;
state.annotSpecs.splice(i, 1);
if (state.selectedId === id) state.selectedId = null;
drawAnnotsForPage(pg);
updateAnnotCount();
buildComments();
}
function cssToPdf(idx, cssX, cssY) {
const p = state.pages[idx];
const scale = PT_PER_PX * state.zoom;
return { x: cssX / scale, y: p.heightPt - cssY / scale };
}
// ===== Sửa Text box tại chỗ (in-place) + thanh Format =====
let editing = null; // { id, el, bar }
function editTextBox(spec) {
finishEditing();
pushUndo(); // ghi lại trạng thái TRƯỚC khi sửa nội dung/định dạng text box
state.selectedId = null;
state.tool = null;
document.querySelectorAll(".atool").forEach((b) => b.classList.remove("active"));
document.body.classList.remove("tool-active");
const slot = state.slots[spec.pageIndex];
const layer = slot.querySelector(".annotlayer");
const scale = PT_PER_PX * state.zoom;
const left = spec.left * scale;
const top = (state.pages[spec.pageIndex].heightPt - spec.top) * scale;
const w = Math.max((spec.right - spec.left) * scale, 60);
const h = Math.max((spec.top - spec.bottom) * scale, 20);
const ed = document.createElement("div");
ed.className = "a-editor";
ed.contentEditable = "true";
ed.textContent = spec.contents || "";
Object.assign(ed.style, {
left: left + "px", top: top + "px", width: w + "px", minHeight: h + "px",
color: rgbCss(spec.color),
fontSize: (spec.fontSize || 14) * scale + "px",
fontWeight: spec.bold ? "bold" : "normal",
fontStyle: spec.italic ? "italic" : "normal",
textDecoration: spec.underline ? "underline" : "none",
});
layer.appendChild(ed);
drawAnnotsForPage(spec.pageIndex); // ẩn preview của spec đang sửa
editing = { id: spec.id, el: ed };
showFmtBar(spec, ed);
ed.focus();
// đặt con trỏ cuối
const sel = window.getSelection();
sel.selectAllChildren(ed);
sel.collapseToEnd();
ed.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); finishEditing(); }
});
}
function finishEditing() {
if (!editing) return;
const spec = state.annotSpecs.find((s) => s.id === editing.id);
const ed = editing.el;
if (spec && ed) {
spec.contents = ed.innerText.trim();
// mở rộng rect theo chiều cao thực tế của editor
const scale = PT_PER_PX * state.zoom;
const realH = ed.offsetHeight / scale;
spec.bottom = spec.top - Math.max(realH, 14);
}
if (ed && ed.parentNode) ed.remove();
if (editing.bar && editing.bar.parentNode) editing.bar.remove();
const pg = spec ? spec.pageIndex : null;
editing = null;
// Xoá text box rỗng (không nhập gì).
if (spec && spec.kind === "freetext" && !spec.contents) {
const i = state.annotSpecs.indexOf(spec);
if (i >= 0) state.annotSpecs.splice(i, 1);
}
if (pg != null) drawAnnotsForPage(pg);
updateAnnotCount();
buildComments();
}
// Thanh định dạng nổi cho text box (size, B, I, U, màu)
function showFmtBar(spec, editorEl) {
const bar = document.createElement("div");
bar.className = "fmtbar";
const r = editorEl.getBoundingClientRect();
bar.style.left = r.left + "px";
bar.style.top = r.top - 40 + "px";
const sizes = [8, 10, 12, 14, 18, 24, 32, 48];
const sizeSel = document.createElement("select");
sizeSel.title = "Cỡ chữ";
for (const s of sizes) {
const o = document.createElement("option");
o.value = s; o.textContent = s;
if (s === (spec.fontSize || 14)) o.selected = true;
sizeSel.appendChild(o);
}
sizeSel.addEventListener("change", () => {
spec.fontSize = Number(sizeSel.value);
editorEl.style.fontSize = spec.fontSize * PT_PER_PX * state.zoom + "px";
editorEl.focus();
});
const mkToggle = (label, prop, cssApply) => {
const b = document.createElement("button");
b.textContent = label;
b.className = "fmtbtn" + (spec[prop] ? " on" : "");
b.addEventListener("mousedown", (e) => e.preventDefault());
b.addEventListener("click", () => {
spec[prop] = !spec[prop];
b.classList.toggle("on", spec[prop]);
cssApply(spec[prop]);
editorEl.focus();
});
return b;
};
bar.appendChild(sizeSel);
bar.appendChild(mkToggle("B", "bold", (on) => editorEl.style.fontWeight = on ? "bold" : "normal"));
const it = mkToggle("I", "italic", (on) => editorEl.style.fontStyle = on ? "italic" : "normal");
it.style.fontStyle = "italic";
bar.appendChild(it);
const un = mkToggle("U", "underline", (on) => editorEl.style.textDecoration = on ? "underline" : "none");
un.style.textDecoration = "underline";
bar.appendChild(un);
const colorBtn = document.createElement("button");
colorBtn.className = "fmtbtn fmt-color";
colorBtn.innerHTML = ` A`;
colorBtn.addEventListener("mousedown", (e) => e.preventDefault());
colorBtn.addEventListener("click", () => {
openColorPopover(colorBtn, spec.color, (rgb) => {
spec.color = rgb;
editorEl.style.color = rgbCss(rgb);
colorBtn.querySelector(".sw").style.background = rgbCss(rgb);
editorEl.focus();
});
});
bar.appendChild(colorBtn);
document.body.appendChild(bar);
editing.bar = bar;
}
// ===== Note popup (xem/sửa/đổi màu/xoá) =====
let notePopupEl = null;
function openNotePopup(spec) {
closeNotePopup();
pushUndo(); // ghi lại trạng thái TRƯỚC khi sửa nội dung/màu ghi chú
const slot = state.slots[spec.pageIndex];
const layer = slot.querySelector(".annotlayer");
const icon = layer.querySelector(`.a-note[data-id="${spec.id}"]`);
const r = (icon || slot).getBoundingClientRect();
const pop = document.createElement("div");
pop.className = "notepop";
pop.style.left = r.right + 6 + "px";
pop.style.top = r.top + "px";
pop.innerHTML = `
Ghi chú ✕
`;
document.body.appendChild(pop);
notePopupEl = pop;
const ta = pop.querySelector(".np-text");
ta.addEventListener("input", () => {
spec.contents = ta.value;
buildComments();
});
pop.querySelector(".np-close").addEventListener("click", closeNotePopup);
pop.querySelector(".np-del").addEventListener("click", () => {
closeNotePopup();
deleteSpec(spec.id);
});
pop.querySelector(".np-color").addEventListener("click", (e) => {
openColorPopover(e.currentTarget, spec.color, (rgb) => {
spec.color = rgb;
pop.querySelector(".np-color .sw").style.background = rgbCss(rgb);
drawAnnotsForPage(spec.pageIndex);
});
});
ta.focus();
}
function closeNotePopup() {
if (notePopupEl) { notePopupEl.remove(); notePopupEl = null; }
}
// ===== Color popover (preset + gần đây + tuỳ chọn) =====
let colorPopEl = null;
function openColorPopover(anchor, currentRgb, onPick) {
closeColorPopover();
const r = anchor.getBoundingClientRect();
const pop = document.createElement("div");
pop.className = "colorpop";
pop.style.left = r.left + "px";
pop.style.top = r.bottom + 4 + "px";
const addSwatch = (rgb, container) => {
const s = document.createElement("div");
s.className = "cs";
s.style.background = rgbCss(rgb);
if (rgb[0] === currentRgb[0] && rgb[1] === currentRgb[1] && rgb[2] === currentRgb[2]) s.classList.add("cur");
s.addEventListener("click", () => {
pushRecentColor(rgb);
onPick(rgb.slice());
closeColorPopover();
});
container.appendChild(s);
};
const grid = document.createElement("div");
grid.className = "cgrid";
for (const c of PRESET_COLORS) addSwatch(c, grid);
pop.appendChild(grid);
if (state.recentColors.length) {
const lbl = document.createElement("div");
lbl.className = "clbl"; lbl.textContent = "Gần đây";
pop.appendChild(lbl);
const rg = document.createElement("div");
rg.className = "cgrid";
for (const c of state.recentColors) addSwatch(c, rg);
pop.appendChild(rg);
}
const custom = document.createElement("label");
custom.className = "ccustom";
custom.innerHTML = `Tuỳ chọn… `;
custom.querySelector("input").addEventListener("input", (e) => {
const rgb = hexToRgb(e.target.value);
pushRecentColor(rgb);
onPick(rgb);
});
custom.querySelector("input").addEventListener("change", closeColorPopover);
pop.appendChild(custom);
document.body.appendChild(pop);
colorPopEl = pop;
}
function closeColorPopover() {
if (colorPopEl) { colorPopEl.remove(); colorPopEl = null; }
}
function pushRecentColor(rgb) {
state.recentColors = state.recentColors.filter((c) => !(c[0] === rgb[0] && c[1] === rgb[1] && c[2] === rgb[2]));
state.recentColors.unshift(rgb.slice());
state.recentColors = state.recentColors.slice(0, 8);
}
function rgbToHex(c) {
return "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
}
let drag = null;
function onPagesMouseDown(e) {
if (!state.tool) {
// click vùng trống: bỏ chọn + đóng popup + kết thúc sửa
if (!e.target.closest(".a-editor")) {
finishEditing();
closeNotePopup();
closeColorPopover();
if (!e.target.dataset || e.target.dataset.id == null) deselectAnnot();
}
return;
}
// Highlight/Underline/Strikeout: để trình duyệt tự xử lý chọn text (textlayer
// pointer-events đã mở qua .tool-mark) — tạo annotation ở mouseup từ Selection.
if (isTextMarkupTool(state.tool)) return;
const slot = e.target.closest && e.target.closest(".page-slot");
if (!slot) return;
e.preventDefault();
const r = slot.getBoundingClientRect();
drag = {
idx: Number(slot.dataset.index),
x0: e.clientX - r.left,
y0: e.clientY - r.top,
x1: e.clientX - r.left,
y1: e.clientY - r.top,
};
}
function onPagesMouseMove(e) {
if (!drag) return;
const slot = state.slots[drag.idx];
const r = slot.getBoundingClientRect();
drag.x1 = e.clientX - r.left;
drag.y1 = e.clientY - r.top;
const layer = slot.querySelector(".annotlayer");
let prev = layer.querySelector(".a-preview");
if (!prev) {
prev = document.createElement("div");
prev.className = "a-hl a-preview";
layer.appendChild(prev);
}
const l = Math.min(drag.x0, drag.x1);
const t = Math.min(drag.y0, drag.y1);
prev.style.cssText =
`left:${l}px;top:${t}px;width:${Math.abs(drag.x1 - drag.x0)}px;height:${Math.abs(drag.y1 - drag.y0)}px;` +
`background:rgba(${state.color[0]},${state.color[1]},${state.color[2]},.3)`;
}
// Mỗi "từ" trong text layer là 1 định vị tuyệt đối riêng (để bám đúng
// pixel PDFium) — vì vậy Range.getClientRects() trả về 1 rect/TỪ, không phải
// 1 rect/DÒNG như với text chảy bình thường. Gộp các rect chồng lấp theo
// chiều dọc (cùng 1 dòng) thành 1 rect duy nhất, để highlight 1 cụm từ liền
// mạch chứ không bị đứt từng từ với khoảng trắng hở giữa.
function mergeRectsIntoLines(rects) {
const sorted = rects.slice().sort((a, b) => a.top - b.top);
const lines = [];
for (const r of sorted) {
const line = lines.find((l) => {
const overlap = Math.min(l.bottom, r.bottom) - Math.max(l.top, r.top);
return overlap > 0.5 * Math.min(l.bottom - l.top, r.bottom - r.top);
});
if (line) {
line.left = Math.min(line.left, r.left);
line.right = Math.max(line.right, r.right);
line.top = Math.min(line.top, r.top);
line.bottom = Math.max(line.bottom, r.bottom);
} else {
lines.push({ left: r.left, right: r.right, top: r.top, bottom: r.bottom });
}
}
return lines;
}
// Tạo Highlight/Underline/Strikeout từ vùng TEXT người dùng vừa chọn (Selection
// API) — bám đúng từng dòng như Foxit, không phải 1 rectangle tự do.
function createMarkupFromSelection() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const anchorNode = range.commonAncestorContainer;
const anchorEl = anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentElement;
const slot = anchorEl && anchorEl.closest(".page-slot");
if (!slot) { sel.removeAllRanges(); return; }
const idx = Number(slot.dataset.index);
const slotRect = slot.getBoundingClientRect();
const rawRects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5);
sel.removeAllRanges();
if (!rawRects.length) return;
const clientRects = mergeRectsIntoLines(rawRects);
const quads = clientRects.map((r) => {
const a = cssToPdf(idx, r.left - slotRect.left, r.top - slotRect.top);
const b = cssToPdf(idx, r.right - slotRect.left, r.bottom - slotRect.top);
return {
left: Math.min(a.x, b.x), right: Math.max(a.x, b.x),
bottom: Math.min(a.y, b.y), top: Math.max(a.y, b.y),
};
});
const bounds = quads.reduce(
(acc, q) => acc ? {
left: Math.min(acc.left, q.left), right: Math.max(acc.right, q.right),
bottom: Math.min(acc.bottom, q.bottom), top: Math.max(acc.top, q.top),
} : { ...q },
null
);
const spec = {
id: annotIdSeq++,
kind: state.tool,
pageIndex: idx,
left: bounds.left, bottom: bounds.bottom, right: bounds.right, top: bounds.top,
quads,
color: [...state.color],
contents: "",
fontSize: 14,
bold: false,
italic: false,
underline: false,
};
pushUndo();
state.annotSpecs.push(spec);
pushRecentColor(state.color);
// Giữ tool active để tô tiếp liên tục (như Foxit) — bấm lại nút hoặc Esc để tắt.
drawAnnotsForPage(idx);
updateAnnotCount();
buildComments();
}
function onPagesMouseUp() {
if (state.tool && isTextMarkupTool(state.tool)) {
createMarkupFromSelection();
return;
}
if (!drag) return;
const d = drag;
drag = null;
const slot = state.slots[d.idx];
const prev = slot.querySelector(".a-preview");
if (prev) prev.remove();
const a = cssToPdf(d.idx, d.x0, d.y0);
const b = cssToPdf(d.idx, d.x1, d.y1);
const rect = {
left: Math.min(a.x, b.x),
right: Math.max(a.x, b.x),
bottom: Math.min(a.y, b.y),
top: Math.max(a.y, b.y),
};
const tool = state.tool;
const scale = PT_PER_PX * state.zoom;
const tiny = rect.right - rect.left < 2 || rect.top - rect.bottom < 2;
if (tool === "crop") {
if (tiny) return;
openCropDialog(d.idx, rect);
return;
}
if (tool === "redact") {
if (tiny) return;
state.redactMarks.push({ page: d.idx, rect });
drawAnnotsForPage(d.idx);
updateRedactButtons();
return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction)
}
if (tool === "note") {
const sz = 18 / scale;
rect.right = rect.left + sz;
rect.bottom = rect.top - sz;
} else if (tool === "freetext") {
if (tiny) {
rect.right = rect.left + 160 / scale;
rect.top = rect.bottom + 24 / scale;
}
} else if (tiny) {
return;
}
const spec = {
id: annotIdSeq++,
kind: tool,
pageIndex: d.idx,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
top: rect.top,
color: [...state.color],
contents: "",
fontSize: 14,
bold: false,
italic: false,
underline: false,
};
pushUndo();
state.annotSpecs.push(spec);
pushRecentColor(state.color);
// FreeText/Note: gõ ngay → về chế độ chọn (như Foxit). Square: giữ tool active
// để vẽ tiếp liên tục.
if (tool === "freetext" || tool === "note") setTool(null);
drawAnnotsForPage(d.idx);
updateAnnotCount();
buildComments();
if (tool === "freetext") editTextBox(spec);
else if (tool === "note") openNotePopup(spec);
}
async function saveAnnots() {
if (!state.annotSpecs.length) return;
finishEditing();
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
const specs = state.annotSpecs.map((s) => ({
kind: s.kind,
pageIndex: s.pageIndex,
left: s.left, bottom: s.bottom, right: s.right, top: s.top,
quads: s.quads || [],
color: [s.color[0], s.color[1], s.color[2], 255],
contents: s.contents || null,
fontSize: s.fontSize || 14,
bold: !!s.bold,
italic: !!s.italic,
underline: !!s.underline,
}));
await invoke("apply_annotations", { input: state.path, output: out, specs });
const n = state.annotSpecs.length;
state.annotSpecs = [];
updateAnnotCount();
$("status").textContent = `Đã lưu ${n} chú thích → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("status").textContent = "Lỗi lưu chú thích: " + e;
}
}
function buildComments() {
const box = $("comments");
box.innerHTML = "";
if (!state.annotSpecs.length) {
box.innerHTML = `Chưa có chú thích chưa lưu. Chọn công cụ ở thanh trên và vẽ lên trang, rồi bấm "Lưu chú thích".
`;
return;
}
const labels = {
highlight: "Tô sáng", underline: "Gạch chân", strikeout: "Gạch ngang",
square: "Khung", freetext: "Text box", note: "Ghi chú",
};
for (const s of state.annotSpecs) {
const el = document.createElement("div");
el.className = "citem";
el.innerHTML =
`✕ ` +
` ` +
`${labels[s.kind] || s.kind} · trang ${s.pageIndex + 1}` +
(s.contents ? `${escapeHtml(s.contents)} ` : "");
el.addEventListener("click", (ev) => {
if (ev.target.classList.contains("cdel")) {
deleteSpec(s.id);
} else {
goToPage(s.pageIndex);
selectAnnot(s.id);
if (s.kind === "note") openNotePopup(s);
}
});
box.appendChild(el);
}
}
// ---------- Copy ----------
async function copyCurrentPage() {
try {
const txt = await invoke("page_text", { path: state.path, page: state.current });
await navigator.clipboard.writeText(txt);
$("status").textContent = `Đã copy text trang ${state.current + 1} (${txt.length} ký tự)`;
} catch (e) {
$("status").textContent = "Lỗi copy: " + e;
}
}
// ---------- Phase 3: Tổ chức trang ----------
function openModal(title, bodyHtml) {
$("modalBox").innerHTML = `${title} ${bodyHtml}`;
$("modalOverlay").classList.remove("hidden");
return $("modalBox");
}
function closeModal() {
$("modalOverlay").classList.add("hidden");
$("modalBox").innerHTML = "";
}
/// Parse "1-3,5" (1-based, kiểu người dùng nhập) → mảng index 0-based hợp lệ.
function parsePageRange(str, count) {
const out = [];
for (const part of str.split(",")) {
const t = part.trim();
if (!t) continue;
const m = t.match(/^(\d+)(?:-(\d+))?$/);
if (!m) continue;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
for (let n = Math.min(a, b); n <= Math.max(a, b); n++) {
if (n >= 1 && n <= count) out.push(n - 1);
}
}
return out;
}
function enterOrganizeMode() {
state.organizeMode = true;
$("annobar").classList.add("hidden");
$("organizeBar").classList.remove("hidden");
$("viewport").classList.add("hidden");
$("organizeGrid").classList.remove("hidden");
$("sidebar").classList.add("hidden");
$("organizeModeBtn").classList.add("active");
setTool(null);
buildOrganizeGrid();
}
function exitOrganizeMode() {
state.organizeMode = false;
$("annobar").classList.remove("hidden");
$("organizeBar").classList.add("hidden");
$("viewport").classList.remove("hidden");
$("organizeGrid").classList.add("hidden");
$("sidebar").classList.remove("hidden");
$("organizeModeBtn").classList.remove("active");
}
function toggleOrganizeMode() {
if (state.organizeMode) exitOrganizeMode();
else enterOrganizeMode();
}
function orgSelectCard(i, e) {
if (e.shiftKey && state.orgAnchor != null) {
const a = Math.min(state.orgAnchor, i);
const b = Math.max(state.orgAnchor, i);
state.orgSelected = new Set();
for (let k = a; k <= b; k++) state.orgSelected.add(k);
} else if (e.ctrlKey || e.metaKey) {
if (state.orgSelected.has(i)) state.orgSelected.delete(i);
else state.orgSelected.add(i);
state.orgAnchor = i;
} else {
state.orgSelected = new Set([i]);
state.orgAnchor = i;
}
// Chỉ đổi class — KHÔNG dựng lại lưới (tránh render lại toàn bộ thumbnail).
refreshOrgSelection();
}
// Chỉ cập nhật trạng thái chọn (viền xanh) trên các card hiện có — không đụng
// tới thumbnail. Tách khỏi buildOrganizeGrid để click chọn không gây render lại.
function refreshOrgSelection() {
const cards = $("organizeGrid").children;
for (let i = 0; i < cards.length; i++) {
cards[i].classList.toggle("selected", state.orgSelected.has(i));
}
}
// pagePlan có khác trạng thái gốc của file đang mở không (đã chèn/xoá/đảo/xoay/
// crop)? Watermark/Header-Footer cần biết để áp đúng lên trạng thái đang xem,
// không phải file gốc còn trên đĩa (xem materializeBaseInput).
function planIsDirty() {
if (state.pagePlan.length !== state.pages.length) return true;
return state.pagePlan.some((e, i) =>
e.kind !== "existing" || e.source || e.srcIndex !== i ||
(e.rotationDelta || 0) !== 0 || e.crop);
}
// Trả về đường dẫn file nên dùng làm "input" cho Watermark/Header-Footer:
// nếu pagePlan đang giữ nguyên trạng thái gốc → dùng thẳng state.path; nếu
// đang có thay đổi tổ chức trang chưa lưu (chèn/xoá/đảo/xoay/crop) → dựng tạm
// 1 file theo ĐÚNG trạng thái đang xem trên lưới (qua organize_materialize,
// dùng lại build_document đã có) rồi dùng file đó — để watermark/đánh số thấy
// đúng những gì người dùng đang thấy, không cần ép họ lưu rồi mở lại.
async function materializeBaseInput() {
if (!planIsDirty()) return { path: state.path, isTemp: false, pageCount: state.pagePlan.length };
const path = await invoke("organize_materialize", {
mainInput: state.path, plan: state.pagePlan, password: null,
});
return { path, isTemp: true, pageCount: state.pagePlan.length };
}
const MATERIALIZED_NOTE =
`Đang dùng bản xem trước đã gồm các thay đổi tổ chức trang ` +
`chưa lưu (chèn/xoá/đảo/xoay/crop) — chưa ghi đè file gốc.
`;
function orgMovePage(from, to) {
if (from === to) return;
pushUndo();
const [moved] = state.pagePlan.splice(from, 1);
state.pagePlan.splice(to, 0, moved);
state.orgSelected = new Set();
state.orgAnchor = null;
buildOrganizeGrid();
}
// Khoá cache thumbnail: 1 ảnh render chỉ phụ thuộc nguồn + trang gốc (rotation
// áp bằng CSS transform, crop chỉ là dấu chấm — đều không cần render lại).
function orgThumbKey(entry) {
return `${entry.source || ""}#${entry.srcIndex}`;
}
// Dựng lại toàn bộ lưới (chỉ gọi khi CẤU TRÚC plan đổi: chèn/xoá/đảo). Thumbnail
// lấy từ cache nếu có; chỉ render trang chưa từng render → click chọn/xoay không
// còn kéo theo render lại toàn bộ.
function buildOrganizeGrid() {
const box = $("organizeGrid");
box.innerHTML = "";
state.pagePlan.forEach((entry, i) => {
const dims = (!entry.source && state.pages[entry.srcIndex])
? state.pages[entry.srcIndex]
: { widthPt: entry.widthPt || 612, heightPt: entry.heightPt || 792 };
const card = document.createElement("div");
card.className = "org-card" + (state.orgSelected.has(i) ? " selected" : "");
card.draggable = true;
card.dataset.index = String(i);
const wrapH = Math.round(160 * (dims.heightPt / dims.widthPt));
card.innerHTML =
`` +
(entry.kind === "blank"
? `
`
: `
`) +
(entry.crop ? `
` : "") +
`
${i + 1}
`;
const img = card.querySelector("img");
if (img) {
const rot = entry.rotationDelta || 0;
if (rot) img.style.transform = `rotate(${rot}deg)`;
const key = orgThumbKey(entry);
const cached = state.orgThumbs.get(key);
if (cached) {
img.src = cached;
} else {
const path = entry.source || state.path;
invoke("render_page", { path, page: entry.srcIndex, width: 160 })
.then((url) => { state.orgThumbs.set(key, url); img.src = url; })
.catch(() => {});
}
}
card.addEventListener("click", (e) => orgSelectCard(i, e));
card.addEventListener("dragstart", (e) => e.dataTransfer.setData("text/plain", String(i)));
card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("drag-over"); });
card.addEventListener("dragleave", () => card.classList.remove("drag-over"));
card.addEventListener("drop", (e) => {
e.preventDefault();
card.classList.remove("drag-over");
const from = Number(e.dataTransfer.getData("text/plain"));
orgMovePage(from, i);
});
box.appendChild(card);
});
$("orgSave").disabled = state.pagePlan.length === 0;
}
function orgDeleteSelected() {
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để xoá"; return; }
if (state.orgSelected.size >= state.pagePlan.length) { $("organizeHint").textContent = "Không thể xoá hết toàn bộ trang"; return; }
pushUndo();
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
state.orgSelected = new Set();
$("organizeHint").textContent = "";
buildOrganizeGrid();
}
function orgRotateSelected(delta) {
if (!state.pagePlan.length) return;
pushUndo();
const targets = state.orgSelected.size ? state.orgSelected : new Set(state.pagePlan.map((_, i) => i));
const cards = $("organizeGrid").children;
for (const i of targets) {
const entry = state.pagePlan[i];
entry.rotationDelta = ((entry.rotationDelta || 0) + delta + 360) % 360;
// Chỉ xoay ảnh bằng CSS — KHÔNG render lại trang.
const img = cards[i] && cards[i].querySelector("img");
if (img) img.style.transform = entry.rotationDelta ? `rotate(${entry.rotationDelta}deg)` : "";
}
}
async function orgSaveChanges() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("organize_apply", { mainInput: state.path, plan: state.pagePlan, output: out, password: null });
$("status").textContent = `Đã lưu thay đổi tổ chức trang → ${shortName(out)}`;
exitOrganizeMode();
loadDocument(out);
} catch (e) {
$("organizeHint").textContent = "Lỗi lưu: " + e;
}
}
function openInsertDialog() {
const box = openModal("Chèn trang", `
Trang trắng
Từ file…
Cỡ giấy
Letter
A4
Tuỳ chọn…
File nguồn
📂 Chọn file…
Trang (vd 1-3,5 — rỗng = tất cả)
Vị trí
Trước trang đang chọn
Sau trang đang chọn
Cuối tài liệu
`);
let insertFile = null;
box.querySelectorAll('input[name=insKind]').forEach((r) => r.addEventListener("change", () => {
const isFile = box.querySelector('input[name=insKind]:checked').value === "file";
box.querySelector("#insBlankOpts").style.display = isFile ? "none" : "";
box.querySelector("#insFileOpts").style.display = isFile ? "" : "none";
}));
box.querySelector("#insPaper").addEventListener("change", (e) => {
box.querySelector("#insCustomSize").style.display = e.target.value === "custom" ? "flex" : "none";
});
box.querySelector("#insPickFile").addEventListener("click", async () => {
const p = await invoke("pick_pdf");
if (p) { insertFile = p; box.querySelector("#insFileName").textContent = shortName(p); }
});
box.querySelector("#insCancel").addEventListener("click", closeModal);
box.querySelector("#insOk").addEventListener("click", async () => {
const kind = box.querySelector('input[name=insKind]:checked').value;
const pos = box.querySelector('input[name=insPos]:checked').value;
let newEntries = [];
if (kind === "blank") {
let w = 612, h = 792;
const paper = box.querySelector("#insPaper").value;
if (paper === "custom") {
w = Number(box.querySelector("#insW").value) || 612;
h = Number(box.querySelector("#insH").value) || 792;
} else {
[w, h] = paper.split("x").map(Number);
}
newEntries = [{ kind: "blank", widthPt: w, heightPt: h, rotationDelta: 0, crop: null }];
} else {
if (!insertFile) { box.querySelector("#insErr").textContent = "Hãy chọn file nguồn."; return; }
let count;
try {
const meta = await invoke("open_document", { path: insertFile });
count = meta.pageCount;
} catch (e) {
box.querySelector("#insErr").textContent = "Không mở được file: " + e;
return;
}
const rangeStr = box.querySelector("#insRange").value.trim();
const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i);
if (!indices.length) { box.querySelector("#insErr").textContent = "Phạm vi trang không hợp lệ."; return; }
newEntries = indices.map((idx) => ({ kind: "existing", source: insertFile, srcIndex: idx, rotationDelta: 0, crop: null }));
}
let at = state.pagePlan.length;
if (pos === "before") at = Math.min(...state.orgSelected);
else if (pos === "after") at = Math.max(...state.orgSelected) + 1;
pushUndo();
state.pagePlan.splice(at, 0, ...newEntries);
state.orgSelected = new Set();
buildOrganizeGrid();
closeModal();
});
}
function openExtractDialog() {
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để trích"; return; }
const box = openModal("Trích trang", `
Trích ${state.orgSelected.size} trang đã chọn ra file PDF mới.
Xoá các trang này khỏi tài liệu sau khi trích
`);
box.querySelector("#extCancel").addEventListener("click", closeModal);
box.querySelector("#extOk").addEventListener("click", async () => {
const out = await invoke("pick_save_pdf");
if (!out) return;
const indices = Array.from(state.orgSelected).sort((a, b) => a - b);
const plan = indices.map((i) => state.pagePlan[i]);
try {
await invoke("organize_apply", { mainInput: state.path, plan, output: out, password: null });
if (box.querySelector("#extDeleteAfter").checked) {
pushUndo();
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
state.orgSelected = new Set();
buildOrganizeGrid();
}
$("organizeHint").textContent = `Đã trích ra ${shortName(out)}`;
closeModal();
} catch (e) {
box.querySelector("#extErr").textContent = "Lỗi: " + e;
}
});
}
function openReplaceDialog() {
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để thay"; return; }
const box = openModal("Thay trang", `
Thay nội dung ${state.orgSelected.size} trang đã chọn bằng trang từ file khác.
File nguồn
📂 Chọn file…
Trang nguồn (vd 1-3,5 — rỗng = tất cả)
`);
let file = null;
box.querySelector("#repPickFile").addEventListener("click", async () => {
const p = await invoke("pick_pdf");
if (p) { file = p; box.querySelector("#repFileName").textContent = shortName(p); }
});
box.querySelector("#repCancel").addEventListener("click", closeModal);
box.querySelector("#repOk").addEventListener("click", async () => {
if (!file) { box.querySelector("#repErr").textContent = "Hãy chọn file nguồn."; return; }
let count;
try {
const meta = await invoke("open_document", { path: file });
count = meta.pageCount;
} catch (e) {
box.querySelector("#repErr").textContent = "Không mở được file: " + e;
return;
}
const rangeStr = box.querySelector("#repRange").value.trim();
const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i);
if (!indices.length) { box.querySelector("#repErr").textContent = "Phạm vi trang không hợp lệ."; return; }
const newEntries = indices.map((idx) => ({ kind: "existing", source: file, srcIndex: idx, rotationDelta: 0, crop: null }));
const sorted = Array.from(state.orgSelected).sort((a, b) => a - b);
const at = sorted[0];
pushUndo();
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
state.pagePlan.splice(Math.min(at, state.pagePlan.length), 0, ...newEntries);
state.orgSelected = new Set();
buildOrganizeGrid();
closeModal();
});
}
function openMergeDialog() {
let files = [];
const box = openModal("Trộn file PDF", `
➕ Thêm file…
`);
function renderList() {
box.querySelector("#mrgList").innerHTML = files.map((f, i) =>
`` +
`${i + 1}. ${shortName(f)} ` +
`↑ ` +
`↓ ` +
`✕
`
).join("");
}
box.querySelector("#mrgAdd").addEventListener("click", async () => {
const p = await invoke("pick_pdf");
if (p) { files.push(p); renderList(); }
});
box.querySelector("#mrgList").addEventListener("click", (e) => {
const t = e.target;
if (t.dataset.up != null) {
const i = Number(t.dataset.up);
[files[i - 1], files[i]] = [files[i], files[i - 1]];
renderList();
} else if (t.dataset.down != null) {
const i = Number(t.dataset.down);
[files[i + 1], files[i]] = [files[i], files[i + 1]];
renderList();
} else if (t.dataset.rm != null) {
files.splice(Number(t.dataset.rm), 1);
renderList();
}
});
box.querySelector("#mrgCancel").addEventListener("click", closeModal);
box.querySelector("#mrgOk").addEventListener("click", async () => {
if (files.length < 1) { box.querySelector("#mrgErr").textContent = "Hãy thêm ít nhất 1 file."; return; }
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("organize_merge", { files, output: out });
$("status").textContent = `Đã trộn ${files.length} file → ${shortName(out)}`;
closeModal();
} catch (e) {
box.querySelector("#mrgErr").textContent = "Lỗi: " + e;
}
});
renderList();
}
function openSplitDialog() {
const box = openModal("Tách file PDF", `
Mỗi file tối đa (số trang)
`);
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 : ""}
Nội dung
Đậm
Nghiêng
Góc xoay (độ)
Vị trí
${["top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right"]
.map((a) => `● `).join("")}
Trang áp dụng (rỗng = tất cả, vd 1-3,5)
`);
let anchor = "center";
box.querySelector("#wmAnchor").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-a]");
if (!btn) return;
anchor = btn.dataset.a;
box.querySelectorAll("#wmAnchor button").forEach((b) => b.classList.toggle("cur", b === btn));
});
function buildSpec() {
const [r, g, b] = box.querySelector("#wmColor").value.split(",").map((x) => Number(x.trim()) || 0);
return {
text: box.querySelector("#wmText").value || "",
fontSize: Number(box.querySelector("#wmSize").value) || 36,
color: [r, g, b, Number(box.querySelector("#wmAlpha").value) || 120],
bold: box.querySelector("#wmBold").checked,
italic: box.querySelector("#wmItalic").checked,
rotationDeg: Number(box.querySelector("#wmRotate").value) || 0,
anchor,
pages: box.querySelector("#wmPages").value.trim()
? parsePageRange(box.querySelector("#wmPages").value.trim(), base.pageCount)
: [],
};
}
box.querySelector("#wmPreview").addEventListener("click", async () => {
try {
const url = await invoke("preview_watermark", { input: base.path, page: previewPage, spec: buildSpec(), width: 500 });
let img = box.querySelector("#wmPreviewImg");
if (!img) {
img = document.createElement("img");
img.id = "wmPreviewImg";
img.style.maxWidth = "100%";
img.style.marginTop = "8px";
box.insertBefore(img, box.querySelector(".foot"));
}
img.src = url;
} catch (e) {
box.querySelector("#wmErr").textContent = "Lỗi xem trước: " + e;
}
});
box.querySelector("#wmCancel").addEventListener("click", closeModal);
box.querySelector("#wmOk").addEventListener("click", async () => {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("watermark_add", { input: base.path, spec: buildSpec(), output: out, password: null });
$("status").textContent = `Đã thêm watermark → ${shortName(out)}`;
closeModal();
loadDocument(out);
} catch (e) {
box.querySelector("#wmErr").textContent = "Lỗi: " + e;
}
});
}
async function openHeaderFooterDialog() {
const base = await materializeBaseInput();
const previewPage = base.isTemp ? (state.orgSelected.size ? Math.min(...state.orgSelected) : 0) : state.current;
const box = openModal("Header / Footer", `
${base.isTemp ? MATERIALIZED_NOTE : ""}
Chèn vào ô đang focus:
{page}
{total}
{date}
Trang áp dụng (rỗng = tất cả)
`);
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", `
Trang này
Tất cả trang
Áp dụng ngay vào kế hoạch tổ chức trang — vào "🗂 Tổ chức trang" → "💾 Lưu thay đổi" để ghi ra file thật.
`);
box.querySelector("#cropCancel").addEventListener("click", () => {
closeModal();
drawAnnotsForPage(pageIdx);
});
box.querySelector("#cropOk").addEventListener("click", () => {
const l = Number(box.querySelector("#cropL").value) || 0;
const r = Number(box.querySelector("#cropR").value) || 0;
const t = Number(box.querySelector("#cropT").value) || 0;
const b = Number(box.querySelector("#cropB").value) || 0;
const scope = box.querySelector('input[name=cropScope]:checked').value;
pushUndo();
for (const entry of state.pagePlan) {
const isThis = !entry.source && entry.srcIndex === pageIdx;
if (scope === "all" || isThis) {
const dims = (!entry.source && state.pages[entry.srcIndex]) ? state.pages[entry.srcIndex] : p;
entry.crop = { left: l, bottom: b, right: dims.widthPt - r, top: dims.heightPt - t };
}
}
closeModal();
setTool(null);
drawAnnotsForPage(pageIdx);
$("status").textContent = "Đã đặt vùng crop — vào Tổ chức trang > Lưu để ghi ra file thật.";
});
}
// ---------- Phase 4: Sửa nội dung (Edit) ----------
// Mô hình "materialize tức thì": mỗi thao tác (sửa text / xoá / thêm / di
// chuyển) được áp NGAY vào 1 file tạm mới (editBase) qua edit_apply, rồi đọc
// lại object + render ảnh từ file đó. Nhờ vậy: index object luôn khớp ảnh đang
// hiện (WYSIWYG thật), không phải tự suy đoán; undo = quay lại file tạm trước.
const EDIT_STAGE_W = 820; // px bề rộng ảnh trang khi sửa
function enterEditMode(pageIndex) {
if (!state.path) { $("status").textContent = "Hãy mở file trước khi sửa nội dung"; return; }
if (state.organizeMode) exitOrganizeMode();
state.editMode = true;
state.editPage = Number.isInteger(pageIndex) ? pageIndex : state.current;
state.editBase = state.path;
state.editUndo = [];
state.editRedo = [];
state.editTemps = [];
state.editSel = null;
state.editArm = null;
state.editPendingImage = null;
$("annobar").classList.add("hidden");
$("editBar").classList.remove("hidden");
$("viewport").classList.add("hidden");
$("editStage").classList.remove("hidden");
$("sidebar").classList.add("hidden");
$("editModeBtn").classList.add("active");
setTool(null);
loadEditPage();
}
// Dọn mọi file làm việc tạm của phiên sửa (backend chỉ xoá đúng ff_edit_*.pdf
// trong thư mục temp nên gọi thoải mái).
function cleanupEditTemps() {
if (state.editTemps.length) {
invoke("edit_cleanup", { paths: state.editTemps.slice() }).catch(() => {});
state.editTemps = [];
}
}
function exitEditMode() {
cleanupEditTemps();
state.editMode = false;
$("annobar").classList.remove("hidden");
$("editBar").classList.add("hidden");
$("viewport").classList.remove("hidden");
$("editStage").classList.add("hidden");
$("sidebar").classList.remove("hidden");
$("editModeBtn").classList.remove("active");
$("editOverlay").innerHTML = "";
}
function toggleEditMode() {
if (state.editMode) exitEditMode();
else enterEditMode();
}
// Đúp chuột vào CHỮ trên viewer thường (chuẩn Foxit Editor): tự vào chế độ
// Sửa nội dung đúng trang + mở ngay ô sửa tại đoạn vừa đúp. Đúp vùng trống
// hoặc đang cầm công cụ chú thích thì không làm gì.
async function onViewerDblClick(e) {
if (state.editMode || state.organizeMode || state.tool) return;
// Chú thích có handler đúp riêng (freetext) hoặc là element tương tác — bỏ qua.
if (e.target.closest(".a-editor, .a-hl, .a-box, .a-text, .a-note, .redact-mark")) return;
const slot = e.target.closest(".page-slot");
if (!slot) return;
const idx = Number(slot.dataset.index);
const r = slot.getBoundingClientRect();
const p = cssToPdf(idx, e.clientX - r.left, e.clientY - r.top);
let objs;
try {
objs = await invoke("edit_list_objects", { path: state.path, page: idx, password: null });
} catch (_) {
return; // trang không đọc được object → giữ nguyên viewer
}
if (!pickTextAt(objs, p.x, p.y)) return; // đúp vùng trống: như Foxit, không làm gì
// Bỏ selection từ đúp chuột (browser tự bôi 1 từ trong text layer).
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
state.editPendingPoint = { x: p.x, y: p.y };
enterEditMode(idx);
}
// Đọc lại object + render ảnh trang hiện tại từ editBase, dựng overlay box.
async function loadEditPage() {
const p = state.pages[state.editPage];
state.editScale = EDIT_STAGE_W / p.widthPt;
try {
const [url, objs] = await Promise.all([
invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }),
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]);
$("editImg").src = url;
state.editObjects = objs;
buildEditOverlay();
$("editHint").textContent = `Trang ${state.editPage + 1} · ${objs.length} đối tượng`;
} catch (e) {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e;
}
$("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào
updateEditUndoButtons();
// Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp.
const pending = state.editPendingPoint;
if (pending) {
state.editPendingPoint = null;
const o = pickTextAt(state.editObjects, pending.x, pending.y);
if (o) {
selectEditObject(o.index);
// Chờ ảnh trang decode xong để mọi phép đo theo ảnh (ô sửa, con trỏ) đúng.
const img = $("editImg");
if (img.decode) { try { await img.decode(); } catch (_) {} }
// Toạ độ chuột giả lập từ điểm PDF đã đúp → con trỏ đặt đúng chỗ đó.
const ir = img.getBoundingClientRect();
startTextEdit(o, {
clientX: ir.left + pending.x * state.editScale,
clientY: ir.top + (p.heightPt - pending.y) * state.editScale,
});
}
}
}
// Text object dưới điểm (x,y) điểm PDF: ưu tiên box CHỨA điểm (nới 3pt, chọn
// box nhỏ nhất khi chồng nhau), fallback box gần nhất trong 12pt.
function pickTextAt(objs, x, y) {
const texts = objs.filter((o) => o.kind === "text");
const pad = 3;
const containing = texts.filter(
(o) => x >= o.rect.left - pad && x <= o.rect.right + pad && y >= o.rect.bottom - pad && y <= o.rect.top + pad
);
if (containing.length) {
containing.sort(
(a, b) =>
(a.rect.right - a.rect.left) * (a.rect.top - a.rect.bottom) -
(b.rect.right - b.rect.left) * (b.rect.top - b.rect.bottom)
);
return containing[0];
}
let best = null;
let bestD = 12;
for (const o of texts) {
const dx = Math.max(o.rect.left - x, 0, x - o.rect.right);
const dy = Math.max(o.rect.bottom - y, 0, y - o.rect.top);
const d = Math.hypot(dx, dy);
if (d < bestD) { bestD = d; best = o; }
}
return best;
}
function editBoxStyle(rect) {
const s = state.editScale;
const p = state.pages[state.editPage];
return {
left: rect.left * s + "px",
top: (p.heightPt - rect.top) * s + "px",
width: Math.max(2, (rect.right - rect.left) * s) + "px",
height: Math.max(2, (rect.top - rect.bottom) * s) + "px",
};
}
function buildEditOverlay() {
const ov = $("editOverlay");
ov.innerHTML = "";
ov.classList.toggle("armed", !!state.editArm);
// TEXT: 1 khung mỗi DÒNG thị giác (như Foxit) — không vẽ ô chi chít từng
// run khi PDF cắt dòng thành từng từ/ký tự. Chọn/kéo/xoá tác động CẢ DÒNG.
const lines = clusterTextLines(state.editObjects);
lines.forEach((line) => {
const box = document.createElement("div");
box.className = "edit-box kind-text";
// Đại diện dòng: ưu tiên run chữ thật (không phải bullet) để đúp mở đúng đoạn.
const rep =
line.runs.find((r) => (r.text || "").trim() && !isMarkerRun(r, line.runs)) ||
line.runs.find((r) => (r.text || "").trim()) ||
line.runs[0];
box.dataset.index = String(rep.index);
box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index));
Object.assign(box.style, editBoxStyle(line.rect));
box.title = composeLineText(line.runs);
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, rep, line.runs));
box.addEventListener("click", (e) => {
if (state.editArm) return;
e.stopPropagation();
selectEditObject(rep.index, line.runs.map((r) => r.index));
});
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep, e); });
ov.appendChild(box);
});
// Ảnh/form: khung theo từng object như cũ (có resize).
state.editObjects.forEach((o) => {
if (o.kind !== "image" && o.kind !== "form") return;
// Form đã liệt kê con riêng (file Canva gói cả trang vào form): khung của
// form phủ kín trang sẽ che hết con → bỏ. Ảnh/form TRONG form: chưa sửa
// được (di chuyển/thay cần địa chỉ lồng) → cũng bỏ khung.
if (o.expanded || o.nested) return;
const box = document.createElement("div");
box.className = "edit-box kind-" + o.kind;
box.dataset.index = String(o.index);
Object.assign(box.style, editBoxStyle(o.rect));
box.title = o.kind;
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o, [o.index]));
box.addEventListener("click", (e) => {
if (state.editArm) return;
e.stopPropagation();
selectEditObject(o.index, [o.index]);
});
ov.appendChild(box);
});
refreshEditSelection();
}
// Cập nhật trạng thái chọn TẠI CHỖ (không dựng lại overlay) — quan trọng để
// double-click không bị mất element giữa 2 lần click.
function refreshEditSelection() {
const ov = $("editOverlay");
ov.querySelectorAll(".edit-box").forEach((box) => {
const isSel = Number(box.dataset.index) === state.editSel;
box.classList.toggle("selected", isSel);
const existing = box.querySelector(".ed-handle");
const o = state.editObjects.find((x) => x.index === Number(box.dataset.index));
const resizable = o && (o.kind === "image" || o.kind === "form");
if (isSel && resizable && !existing) {
const h = document.createElement("div");
h.className = "ed-handle";
h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o));
box.appendChild(h);
} else if ((!isSel || !resizable) && existing) {
existing.remove();
}
});
}
// Chọn 1 khung. `runIndices`: mọi run thuộc khung (dòng text = nhiều run) —
// thao tác xoá/kéo/đổi thuộc tính áp cho TẤT CẢ.
function selectEditObject(index, runIndices) {
state.editSel = index != null && index >= 0 ? index : null;
state.editSelRuns = state.editSel != null ? (runIndices || [state.editSel]) : [];
const o = state.editObjects.find((x) => x.index === state.editSel);
const isText = o && o.kind === "text";
const isImage = o && o.kind === "image";
$("edDelete").disabled = !o;
$("edReplaceImage").disabled = !isImage;
$("edFontSize").disabled = !isText;
$("edColorBtn").disabled = !isText;
$("edFontFamily").disabled = !isText;
$("edBold").disabled = !isText;
$("edItalic").disabled = !isText;
if (isText) {
$("edFontSize").value = Math.round(o.fontSize || 12);
if (o.color) { state.editColor = o.color.slice(0, 3); $("edSw").style.background = rgbCss(state.editColor); }
// Ô font: mặc định "(giữ nguyên: )" — chỉ đổi khi người dùng chọn khác.
$("edFontFamily").options[0].textContent = o.fontFamily
? `(giữ nguyên: ${o.fontFamily})`
: "(giữ nguyên)";
$("edFontFamily").value = "";
$("edBold").classList.toggle("on", !!o.fontBold);
$("edItalic").classList.toggle("on", !!o.fontItalic);
const emb = o.fontEmbedded == null ? "" : o.fontEmbedded ? " · font nhúng" : " · font hệ thống";
$("editHint").textContent =
`${o.fontFamily || o.fontName || "?"} · ${Math.round(o.fontSize || 0)}pt${emb}`;
} else {
$("edFontFamily").options[0].textContent = "(giữ nguyên)";
$("edBold").classList.remove("on");
$("edItalic").classList.remove("on");
}
refreshEditSelection();
}
function updateEditUndoButtons() {
// Dùng chung 2 nút Hoàn tác/Làm lại toàn cục khi đang ở chế độ sửa.
if (!state.editMode) return;
$("undoBtn").disabled = state.editUndo.length === 0;
$("redoBtn").disabled = state.editRedo.length === 0;
}
// Áp 1 nhóm op (1 thao tác người dùng): lưu editBase cũ vào stack undo,
// materialize ra file tạm mới, ghi nhận file tạm để dọn khi thoát.
async function stageEditOps(ops) {
try {
const out = await invoke("edit_apply_to_temp", {
input: state.editBase, page: state.editPage, ops, password: null,
});
state.editTemps.push(out);
state.editUndo.push(state.editBase);
state.editRedo = [];
state.editBase = out;
state.editSel = null;
selectEditObject(-1);
await loadEditPage();
} catch (e) {
$("editHint").textContent = "Lỗi: " + e;
}
}
function stageEditOp(op) {
return stageEditOps([op]);
}
function editUndo() {
if (!state.editUndo.length) return;
state.editRedo.push(state.editBase);
state.editBase = state.editUndo.pop();
state.editSel = null;
loadEditPage();
}
function editRedo() {
if (!state.editRedo.length) return;
state.editUndo.push(state.editBase);
state.editBase = state.editRedo.pop();
state.editSel = null;
loadEditPage();
}
// CSS font-family xấp xỉ cho family PDF (để ô sửa hiển thị đúng dáng chữ).
// Tên family từ engine là dạng PostScript CamelCase KHÔNG dấu cách
// ("TimesNewRoman", "SegoeUI", "OpenSans") — không khớp tên font cài trên máy
// nên CSS rơi về serif/sans-serif mặc định (ô sửa sai font). Map alias phổ biến
// + tự tách CamelCase thành tên có dấu cách.
const FONT_CSS_ALIASES = {
helvetica: "Arial",
helveticaneue: "Arial",
arial: "Arial",
arialmt: "Arial",
times: "Times New Roman",
timesroman: "Times New Roman",
timesnewroman: "Times New Roman",
timesnewromanps: "Times New Roman",
courier: "Courier New",
couriernew: "Courier New",
couriernewps: "Courier New",
segoeui: "Segoe UI",
};
function cssFontStack(family) {
if (!family) return "sans-serif";
const k = family.toLowerCase().replace(/[^a-z0-9]/g, "");
const generic = /times|georgia|garamond|palatino|cambria|book|serif/.test(k)
? "serif"
: /courier|consolas|mono/.test(k)
? "monospace"
: "sans-serif";
const parts = [`"${family}"`];
const spaced = family.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
if (spaced !== family) parts.push(`"${spaced}"`);
const alias = FONT_CSS_ALIASES[k];
if (alias && alias !== family && alias !== spaced) parts.push(`"${alias}"`);
parts.push(generic);
return parts.join(", ");
}
// Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ.
function composeLineText(runs) {
let s = "";
for (let i = 0; i < runs.length; i++) {
const t = runs[i].text || "";
if (i > 0) {
const gap = runs[i].rect.left - runs[i - 1].rect.right;
const em = (runs[i].fontSize || 12) * 0.28;
if (gap > em && !s.endsWith(" ") && !t.startsWith(" ")) s += " ";
}
s += t;
}
return s;
}
// Gom TOÀN BỘ text run của trang thành các DÒNG thị giác. PDF từ Word hay cắt
// 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, bbox chữ có dấu (Ạ, Ợ)
// tụt thấp hơn hẳn — nên cluster theo GIAO DỌC (≥50% chiều cao nhỏ hơn) thay
// vì so baseline, rồi tách cột theo khoảng hở ngang lớn. Trả mảng dòng
// {runs (trái→phải, gồm cả run rỗng trong tầm), rect (union), fs (cỡ lớn nhất)}.
function clusterTextLines(objs) {
const texts = objs.filter((o) => o.kind === "text");
const real = texts.filter((o) => o.rect.top - o.rect.bottom > 0.5 && o.rect.right - o.rect.left > 0.1);
const degenerate = texts.filter((o) => !real.includes(o)); // run rỗng/điểm
// (1) Cụm theo giao dọc.
const bands = [];
for (const r of real.slice().sort((a, b) => b.rect.bottom - a.rect.bottom)) {
const h = r.rect.top - r.rect.bottom;
const band = bands.find((bd) => {
const ov = Math.min(bd.top, r.rect.top) - Math.max(bd.bottom, r.rect.bottom);
return ov > 0.5 * Math.min(bd.top - bd.bottom, h);
});
if (band) {
band.runs.push(r);
band.top = Math.max(band.top, r.rect.top);
band.bottom = Math.min(band.bottom, r.rect.bottom);
} else {
bands.push({ top: r.rect.top, bottom: r.rect.bottom, runs: [r] });
}
}
// (2) Trong mỗi cụm dọc: xếp trái→phải, tách dòng/cột khi hở quá lớn.
const lines = [];
for (const band of bands) {
band.runs.sort((a, b) => a.rect.left - b.rect.left);
let cur = null;
for (const r of band.runs) {
const fs = r.fontSize || 12;
const gapMax = Math.max(fs * 1.7, 10);
if (cur && r.rect.left - cur.rect.right <= gapMax) {
cur.runs.push(r);
cur.rect.right = Math.max(cur.rect.right, r.rect.right);
cur.rect.top = Math.max(cur.rect.top, r.rect.top);
cur.rect.bottom = Math.min(cur.rect.bottom, r.rect.bottom);
cur.fs = Math.max(cur.fs, fs);
} else {
cur = { runs: [r], fs, rect: { left: r.rect.left, right: r.rect.right, top: r.rect.top, bottom: r.rect.bottom } };
lines.push(cur);
}
}
}
// (3) Gắn run rỗng vào dòng chứa nó (để khi sửa xoá sạch, không sót).
for (const d of degenerate) {
const line = lines.find(
(l) =>
d.rect.bottom >= l.rect.bottom - 3 &&
d.rect.bottom <= l.rect.top + 3 &&
d.rect.left >= l.rect.left - 6 &&
d.rect.left <= l.rect.right + 6
);
if (line) line.runs.push(d);
}
for (const l of lines) l.runs.sort((a, b) => a.rect.left - b.rect.left);
return lines;
}
// Run "marker" đầu dòng (bullet/dấu gạch danh sách): Word/LibreOffice xuất
// bullet bằng font Symbol/Wingdings với mã PUA (U+F0B7…) — DOM không render
// được (hiện ô vuông □) và Foxit cũng KHÔNG cho sửa bullet như chữ thường.
// Nhận diện để LOẠI khỏi ô sửa + commit (bullet giữ nguyên trên trang).
const BULLET_CHARS = new Set([..."•◦▪▫‣·∙●○■□◆♦►▶➢➤§–—"]);
function isMarkerRun(run, lineRuns) {
const t = (run.text || "").trim();
if (!t || [...t].length > 2) return false;
// Phải là run có chữ TRÁI NHẤT của dòng.
const firstReal = lineRuns.find((r) => (r.text || "").trim());
if (firstReal !== run) return false;
const chars = [...t];
const puaOrBullet = chars.every((ch) => {
const c = ch.codePointAt(0);
return (c >= 0xe000 && c <= 0xf8ff) || BULLET_CHARS.has(ch);
});
if (puaOrBullet) return true;
if (/symbol|wingding|dingbat|marlett|webding/i.test(run.fontFamily || "")) return true;
// Bullet ASCII mơ hồ ("-", "*", "o"): chỉ nhận khi có KHOẢNG HỞ rõ với chữ sau.
if (chars.length === 1 && "-*o".includes(t)) {
const next = lineRuns.find((r) => r !== run && (r.text || "").trim());
if (next) {
const gap = next.rect.left - run.rect.right;
return gap > (run.fontSize || 12) * 0.4;
}
}
return false;
}
// Bỏ marker khỏi 1 dòng cluster: runs còn lại + rect/fs tính lại từ phần chữ
// thật (để ô sửa, thụt lề, so-format không dính bbox của bullet).
function stripLineMarkers(line) {
const marker = line.runs.find((r) => isMarkerRun(r, line.runs));
if (!marker) return line;
const runs = line.runs.filter((r) => r !== marker);
const real = runs.filter((r) => (r.text || "").trim() && r.rect.top - r.rect.bottom > 0.5);
if (!real.length) return { runs, fs: line.fs, rect: { ...line.rect } };
return {
runs,
fs: real.reduce((m, r) => Math.max(m, r.fontSize || 12), 0) || line.fs,
rect: {
left: Math.min(...real.map((r) => r.rect.left)),
right: Math.max(...real.map((r) => r.rect.right)),
top: Math.max(...real.map((r) => r.rect.top)),
bottom: Math.min(...real.map((r) => r.rect.bottom)),
},
};
}
// Format đại diện của 1 dòng (từ run có chữ đầu tiên) — để so "cùng đoạn".
function lineFormat(l) {
const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0];
if (!rep) return null;
return {
fam: (rep.fontFamily || "").toLowerCase(),
bold: !!rep.fontBold,
italic: !!rep.fontItalic,
color: (rep.color || [0, 0, 0]).slice(0, 3).join(","),
};
}
// Gom ĐOẠN VĂN quanh run `o` — CHUẨN FOXIT: mỗi đoạn là 1 khối riêng, chỉ gom
// các dòng kề nhau CÙNG FORMAT (font + đậm/nghiêng + màu, cỡ chênh ≤1.25× —
// tiêu đề 22.5/20pt vẫn 1 đoạn nhưng tiêu đề ≠ thân bài ≠ dòng code), khoảng
// hở ≤1.3× chiều cao dòng, giao ngang ≥30%, nhịp dòng đều ±35%. Bullet đầu
// dòng bị loại khỏi khối (giữ nguyên trên trang). Trả mảng dòng trên→dưới.
function paragraphLines(o) {
const raw = clusterTextLines(state.editObjects);
const rawMine = raw.find((l) => l.runs.some((r) => r.index === o.index));
if (!rawMine) return [ { runs: [o], fs: o.fontSize || 12, rect: { ...o.rect } } ];
const pairs = raw
.map((l) => ({ raw: l, s: stripLineMarkers(l) }))
.filter((p) => p.s.runs.some((r) => (r.text || "").trim()));
const minePair = pairs.find((p) => p.raw === rawMine);
if (!minePair) return [rawMine]; // dòng chỉ có mỗi bullet (hiếm) → sửa mình nó
const all = pairs.map((p) => p.s);
const mine = minePair.s;
const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom);
const overlaps = (a, b) =>
Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left) >
0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left);
const sizeOk = (a, b) => {
const r = a.fs / b.fs;
return r <= 1.25 && r >= 1 / 1.25;
};
const myFmt = lineFormat(mine);
const fmtOk = (l) => {
const f = lineFormat(l);
return (
f && myFmt && f.fam === myFmt.fam && f.bold === myFmt.bold &&
f.italic === myFmt.italic && f.color === myFmt.color
);
};
const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới
const iMine = sorted.indexOf(mine);
const block = [mine];
let advance = null;
// Mở rộng XUỐNG rồi LÊN, khoá nhịp khoảng cách dòng ±35% sau lần đầu.
const tryDir = (step) => {
let edge = mine;
for (let i = iMine + step; i >= 0 && i < sorted.length; i += step) {
const cand = sorted[i];
const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top;
const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand));
if (gap < -3 || gap > maxGap) break;
if (!overlaps(edge, cand) || !sizeOk(edge, cand) || !fmtOk(cand)) break;
const adv = Math.abs(edge.rect.bottom - cand.rect.bottom);
if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break;
if (advance == null) advance = adv;
if (step > 0) block.push(cand);
else block.unshift(cand);
edge = cand;
}
};
tryDir(+1);
tryDir(-1);
return block.map((l) => l); // mỗi phần tử: {runs, fs, rect}
}
// Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một
// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow.
// `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp.
function startTextEdit(o, ev) {
startBlockTextEdit(o, paragraphLines(o), ev);
}
// Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối,
// MỖI DÒNG GỐC = 1 div giữ ĐÚNG font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ (tiêu đề
// 22.5/20pt hiện đúng 2 cỡ), khối căn giữa hiện căn giữa — chữ trong ô nằm
// TRÙNG vị trí chữ gốc, không xê dịch. Enter = xuống dòng; click trong ô =
// di chuyển con trỏ; commit → engine reflow (xoá sạch run cũ, giữ font gốc,
// giữ cỡ từng dòng, giữ căn giữa).
function startBlockTextEdit(o, lines, ev) {
const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove();
const s = state.editScale;
const p = state.pages[state.editPage];
const allRuns = lines.flatMap((l) => l.runs);
const union = {
left: Math.min(...lines.map((l) => l.rect.left)),
right: Math.max(...lines.map((l) => l.rect.right)),
bottom: Math.min(...lines.map((l) => l.rect.bottom)),
top: Math.max(...lines.map((l) => l.rect.top)),
};
const bw = union.right - union.left;
const bc = (union.left + union.right) / 2;
// Căn giữa? — cùng tiêu chí với engine: tâm mọi dòng ≈ tâm khối và có ít
// nhất 1 dòng thụt vào so với lề trái.
const tol = Math.max(2, bw * 0.02);
const centered =
lines.every((l) => Math.abs((l.rect.left + l.rect.right) / 2 - bc) <= tol) &&
lines.some((l) => l.rect.left - union.left > tol);
const original = lines.map((l) => composeLineText(l.runs)).join("\n");
const ce = document.createElement("div");
ce.className = "edit-inline edit-inline-wys";
ce.contentEditable = "plaintext-only";
ce.spellcheck = false;
// Khung: khối căn giữa nở đều 2 phía (tâm giữ nguyên → chữ không xê dịch);
// khối thường giữ mép trái, nở sang phải — khớp giới hạn nở 35% của engine.
let leftPt;
let widthPt;
if (centered) {
const grow = Math.min(bw * 0.175, bc - 8 - bw / 2, p.widthPt - 8 - bc - bw / 2);
const g = Math.max(0, grow);
leftPt = union.left - g;
widthPt = bw + 2 * g;
} else {
leftPt = union.left;
widthPt = Math.min(bw * 1.35, p.widthPt - union.left - 8);
}
// Advance TỪNG DÒNG = khoảng cách baseline dòng này → dòng dưới (xấp xỉ
// bằng hiệu mép DƯỚI 2 bbox — bbox PDF ôm sát glyph nên bottom bám baseline).
// KHÔNG dùng khoảng hở giữa 2 bbox (bottom trên − top dưới ≈ 0/âm) — chính
// là bug làm các dòng dồn cục 8px.
const perLineAdvances = [];
for (let i = 0; i < lines.length; i++) {
let advPt;
if (i < lines.length - 1) {
advPt = lines[i].rect.bottom - lines[i + 1].rect.bottom;
}
if (!(advPt > 0)) advPt = (lines[i].fs || 12) * 1.25;
perLineAdvances.push(Math.max(8, advPt * s));
}
const totalHeightPx = perLineAdvances.reduce((a, b) => a + b, 0);
// Bù lệch dọc: line-box của trình duyệt căn giữa glyph trong line-height,
// còn bbox PDF ôm sát glyph → đẩy khung lên (line-height − cỡ chữ)/2 cho trùng.
const fs0 = (lines[0].fs || o.fontSize || 12) * s;
const topPx = Math.max(0, (p.heightPt - union.top) * s - Math.max(0, (perLineAdvances[0] - fs0) / 2));
Object.assign(ce.style, {
left: leftPt * s + "px",
top: topPx + "px",
width: widthPt * s + "px",
minHeight: totalHeightPx + 4 + "px",
textAlign: centered ? "center" : "left",
});
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const rep = l.runs.find((r) => (r.text || "").trim()) || l.runs[0] || o;
const div = document.createElement("div");
div.className = "edit-line";
const t = composeLineText(l.runs);
if (t) div.textContent = t;
else div.innerHTML = " ";
const divFs = Math.max(6, (l.fs || 12) * s);
div.style.fontSize = divFs + "px";
div.style.lineHeight = perLineAdvances[i] + "px";
div.style.fontFamily = cssFontStack(rep.fontFamily || o.fontFamily);
div.style.fontWeight = (rep.fontBold != null ? rep.fontBold : o.fontBold) ? "bold" : "normal";
div.style.fontStyle = (rep.fontItalic != null ? rep.fontItalic : o.fontItalic) ? "italic" : "normal";
if (rep.color || o.color) div.style.color = rgbCss(rep.color || o.color);
// Giữ thụt lề từng dòng so với mép khối (dòng đầu đoạn thụt vào…).
if (!centered) {
const indent = (l.rect.left - leftPt) * s;
if (indent > 0.5) div.style.textIndent = indent + "px";
}
ce.appendChild(div);
}
// Ẩn khung dòng overlay của các run đang sửa — nền ô trong suốt nên khung
// này lộ qua gây nhiễu; khôi phục khi đóng ô.
ov.querySelectorAll(".edit-box.kind-text").forEach((b) => {
const rIdxs = JSON.parse(b.dataset.runs || "[]");
if (rIdxs.some((i) => allRuns.some((r) => r.index === i))) b.style.display = "none";
});
ov.appendChild(ce);
// WebView không hỗ trợ plaintext-only (hiếm) → contenteditable thường.
if (!ce.isContentEditable) ce.contentEditable = "true";
// ĐO chữ vừa render rồi HIỆU CHỈNH cỡ/vị trí từng dòng cho TRÙNG bbox gốc —
// phải chạy TRƯỚC focus/đặt con trỏ để caretRangeFromPoint trúng layout cuối.
fitEditLinesToPdf(ce, lines, s, p);
ce.focus();
// NỀN TRONG SUỐT chuẩn Foxit: render nền trang ĐÃ ẨN các run đang sửa (áp
// Delete ra file tạm chỉ để lấy ảnh — commit vẫn tính trên editBase gốc).
// Trong lúc chờ render giữ nền trắng che chữ cũ, xong mới chuyển trong suốt
// → thấy nguyên dải màu nền/bullet/kẻ bảng dưới ô sửa.
const img = $("editImg");
const prevSrc = img.src;
let bgSwapped = false;
(async () => {
try {
const tmp = await invoke("edit_apply_to_temp", {
input: state.editBase,
page: state.editPage,
ops: allRuns.map((r) => ({ op: "delete", index: r.index })),
password: null,
});
state.editTemps.push(tmp);
const url = await invoke("render_page", { path: tmp, page: state.editPage, width: EDIT_STAGE_W });
if (ce.isConnected) {
img.src = url;
bgSwapped = true;
ce.style.background = "transparent";
}
} catch (_) { /* không render được thì giữ nền trắng như cũ */ }
})();
// Đặt con trỏ đúng chỗ vừa đúp (như Foxit); không có toạ độ → đầu đoạn.
let placed = false;
if (ev && document.caretRangeFromPoint) {
const r = document.caretRangeFromPoint(ev.clientX, ev.clientY);
if (r && ce.contains(r.startContainer)) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
placed = true;
}
}
if (!placed) {
const sel = window.getSelection();
const r = document.createRange();
r.setStart(ce, 0);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
}
ce.scrollIntoView({ block: "center", behavior: "instant" });
$("editHint").textContent =
"Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ";
// Đọc nội dung theo DÒNG từ contenteditable (div/br → \n).
const readText = () => {
let t = ce.innerText != null ? ce.innerText : ce.textContent;
t = t.replace(/\u00a0/g, " ").replace(/\n+$/, "");
return t;
};
let done = false;
const commit = (save) => {
if (done) return;
done = true;
const text = readText();
ce.remove();
ov.querySelectorAll(".edit-box").forEach((b) => { b.style.display = ""; });
const changed = save && text.trim() && text !== original;
// Huỷ/không đổi gì → trả lại ảnh trang gốc (đang hiện bản ẩn run sửa).
if (!changed && bgSwapped) img.src = prevSrc;
if (changed) {
stageEditOp({
op: "reflowText",
indices: allRuns.map((r) => r.index),
text,
});
} else {
$("editHint").textContent = "";
}
};
ce.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); commit(false); }
else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); }
e.stopPropagation();
});
// Dán luôn ở dạng chữ thuần (khi rơi về contenteditable thường).
ce.addEventListener("paste", (e) => {
if (ce.contentEditable === "plaintext-only") return;
e.preventDefault();
const t = (e.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, t);
});
// Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit).
ce.addEventListener("mousedown", (e) => e.stopPropagation());
ce.addEventListener("click", (e) => e.stopPropagation());
ce.addEventListener("dblclick", (e) => e.stopPropagation());
ce.addEventListener("blur", () => commit(true));
}
// ĐO chữ đã render trong ô sửa rồi HIỆU CHỈNH cho trùng khít chữ gốc — vòng
// đo-bù nên đúng với MỌI loại file, không phụ thuộc engine trả cỡ chuẩn hay
// công thức line-height của trình duyệt:
// (1) CỠ CHỮ: chiều cao MỰC của đúng chuỗi đó (canvas actualBoundingBox) phải
// bằng chiều cao bbox dòng trong PDF (cùng là ink-bbox, gồm cả dấu tiếng
// Việt) — engine trả cỡ lệch (matrix scale lạ) hay font thay thế lệch
// metric đều tự sửa; lệch <12% coi như khác biệt metric, giữ nguyên.
// (2) VỊ TRÍ: quy cả 2 phía về MÉP MỰC TRÊN của dòng (DOM: đỉnh em-box của
// Range + (fontAscent − inkAscent); PDF: rect.top) rồi bù margin-top /
// text-indent từng dòng. Dòng đầu bù vào chính khung (giữ nền trắng che
// kín chữ cũ), các dòng sau bù margin (đo tuần tự nên tự cộng dồn đúng).
function fitEditLinesToPdf(ce, lines, s, page) {
const img = $("editImg");
if (!img) return;
const canvas = (fitEditLinesToPdf._c = fitEditLinesToPdf._c || document.createElement("canvas"));
const ctx = canvas.getContext("2d");
const divs = Array.from(ce.children);
const centered = ce.style.textAlign === "center";
const fontOf = (div, fsOverride) => {
const fs = parseFloat(div.style.fontSize) || 12;
const style = div.style.fontStyle === "italic" ? "italic " : "";
const weight = div.style.fontWeight === "bold" ? "bold " : "";
return { fs, css: `${style}${weight}${fsOverride || fs}px ${div.style.fontFamily || "sans-serif"}` };
};
// (1) Cỡ chữ theo chiều cao mực thật. Đo ở cỡ THAM CHIẾU lớn (100px) rồi
// suy tuyến tính — đo trực tiếp ở cỡ nhỏ bị hinting làm tròn lệch tới ~5%.
const REF = 100;
for (let i = 0; i < divs.length && i < lines.length; i++) {
const text = (divs[i].textContent || "").trim();
if (!text) continue;
const f = fontOf(divs[i], REF);
ctx.font = f.css;
const m = ctx.measureText(text);
const inkPerPx = ((m.actualBoundingBoxAscent || 0) + (m.actualBoundingBoxDescent || 0)) / REF;
const targetPx = (lines[i].rect.top - lines[i].rect.bottom) * s;
if (inkPerPx > 0.01 && targetPx > 1) {
const wantFs = targetPx / inkPerPx;
const ratio = wantFs / f.fs;
if (ratio < 0.88 || ratio > 1.12) {
divs[i].style.fontSize = f.fs * Math.max(0.3, Math.min(3, ratio)) + "px";
}
}
}
// (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ).
const imgR = img.getBoundingClientRect();
for (let i = 0; i < divs.length && i < lines.length; i++) {
const r = firstTextRect(divs[i]);
if (!r) continue;
const text = (divs[i].textContent || "").trim();
const f = fontOf(divs[i]);
ctx.font = f.css;
const m = ctx.measureText(text);
const fontAscent = m.fontBoundingBoxAscent || f.fs * 0.8;
const inkAscent = m.actualBoundingBoxAscent || fontAscent;
const curInkTop = r.top + (fontAscent - inkAscent);
const dy = imgR.top + (page.heightPt - lines[i].rect.top) * s - curInkTop;
if (Math.abs(dy) > 0.5) {
if (i === 0) ce.style.top = parseFloat(ce.style.top) + dy + "px";
else divs[i].style.marginTop = (parseFloat(divs[i].style.marginTop) || 0) + dy + "px";
}
// Ngang: khối căn trái bù qua ce.left (dòng đầu) / text-indent (dòng sau);
// khối căn giữa bù mọi dòng qua text-indent (indent cộng thêm vào offset
// căn giữa nên vẫn dịch được từng dòng mà không phá cơ chế center).
const dx = imgR.left + lines[i].rect.left * s - r.left;
if (Math.abs(dx) > 0.5) {
if (!centered && i === 0) ce.style.left = parseFloat(ce.style.left) + dx + "px";
else divs[i].style.textIndent = (parseFloat(divs[i].style.textIndent) || 0) + dx + "px";
}
}
}
// Bbox render thật của text trong div — đo bằng Range (chỉ tính chữ, không
// tính line-box nên không dính line-height); null nếu dòng rỗng.
function firstTextRect(div) {
const tn = div.firstChild;
if (!tn || tn.nodeType !== Node.TEXT_NODE) return null;
const range = document.createRange();
range.selectNodeContents(tn);
const rects = range.getClientRects();
return rects.length ? rects[0] : null;
}
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
// từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN.
function applyTextPropToSelected(part) {
const runs = state.editSelRuns
.map((i) => state.editObjects.find((x) => x.index === i))
.filter((x) => x && x.kind === "text");
if (!runs.length) return;
stageEditOps(
runs.map((r) => ({
op: "setText",
index: r.index,
text: r.text || "",
fontSize: null,
color: null,
fontFamily: null,
bold: null,
italic: null,
...part,
}))
);
}
function deleteSelectedEditObject() {
if (!state.editSelRuns.length) return;
stageEditOps(state.editSelRuns.map((i) => ({ op: "delete", index: i })));
}
// Click lên trang khi đang "armed" để đặt chữ/ảnh mới.
function onEditStageClick(e) {
// Click vào ô sửa đang mở (di chuyển con trỏ) — không được đụng overlay.
if (e.target.closest && e.target.closest(".edit-inline")) return;
if (!state.editArm) { selectEditObject(-1); state.editSel = null; buildEditOverlay(); return; }
const img = $("editImg");
const r = img.getBoundingClientRect();
const cssX = e.clientX - r.left;
const cssY = e.clientY - r.top;
const p = state.pages[state.editPage];
const pdfX = cssX / state.editScale;
const pdfY = p.heightPt - cssY / state.editScale;
if (state.editArm === "text") {
state.editArm = null;
$("edAddText").classList.remove("armed");
promptAddText(pdfX, pdfY);
} else if (state.editArm === "image") {
const path = state.editPendingImage;
state.editArm = null;
state.editPendingImage = null;
$("edAddImage").classList.remove("armed");
if (path) {
// Khung mặc định 150×112pt (4:3) — người dùng kéo handle để chỉnh lại.
stageEditOp({ op: "addImage", x: pdfX, y: pdfY - 112, widthPt: 150, heightPt: 112, imagePath: path });
}
}
$("editOverlay").classList.remove("armed");
}
function promptAddText(pdfX, pdfY) {
const ov = $("editOverlay");
const inp = document.createElement("input");
inp.className = "edit-inline";
inp.placeholder = "Nhập chữ…";
const s = state.editScale;
const p = state.pages[state.editPage];
const family = $("edFontFamily").value || null; // null = font mặc định
const size = Number($("edFontSize").value) || 16;
inp.style.left = pdfX * s + "px";
inp.style.top = (p.heightPt - pdfY) * s + "px";
inp.style.minWidth = "120px";
inp.style.fontSize = Math.max(10, size * s) + "px";
inp.style.fontFamily = cssFontStack(family);
inp.style.color = rgbCss(state.editColor);
ov.appendChild(inp);
inp.focus();
let done = false;
const commit = (save) => {
if (done) return; done = true;
const text = inp.value;
inp.remove();
if (save && text.trim()) {
stageEditOp({
op: "addText", x: pdfX, y: pdfY, text,
fontSize: size, color: [state.editColor[0], state.editColor[1], state.editColor[2], 255],
fontFamily: family,
bold: null, italic: null,
});
}
};
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(true); }
else if (e.key === "Escape") { e.preventDefault(); commit(false); }
e.stopPropagation();
});
inp.addEventListener("blur", () => commit(true));
}
// Kéo để di chuyển khung đang chọn (dòng text = kéo CẢ DÒNG) — box đi theo
// con trỏ NGAY (live feedback như Foxit), thả chuột mới commit vào engine.
function onEditBoxMouseDown(e, o, runs) {
if (state.editArm) return;
if (e.target.classList.contains("ed-handle")) return;
const runObjs = runs && runs.length ? runs : [o];
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
selectEditObject(o.index, runIndices);
// Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ
// kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return;
const box = e.currentTarget;
const startX = e.clientX, startY = e.clientY;
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
let moved = false;
const onMove = (ev) => {
const dx = ev.clientX - startX, dy = ev.clientY - startY;
if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
moved = true;
box.classList.add("dragging");
}
if (moved) {
box.style.left = left0 + dx + "px";
box.style.top = top0 + dy + "px";
}
};
const onUp = (ev) => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
box.classList.remove("dragging");
if (!moved) return;
const dx = (ev.clientX - startX) / state.editScale;
const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm
stageEditOps(runIndices.map((i) => ({ op: "transform", index: i, dx, dy, sx: 1, sy: 1 })));
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
e.preventDefault();
}
// Kéo handle góc dưới-phải để resize — khung đổi kích thước live, thả commit.
function onEditResizeMouseDown(e, o) {
e.stopPropagation();
e.preventDefault();
const box = e.target.parentElement;
const startX = e.clientX, startY = e.clientY;
const w0 = (o.rect.right - o.rect.left), h0 = (o.rect.top - o.rect.bottom);
const cssW0 = parseFloat(box.style.width), cssH0 = parseFloat(box.style.height);
const onMove = (ev) => {
box.classList.add("dragging");
box.style.width = Math.max(4, cssW0 + (ev.clientX - startX)) + "px";
box.style.height = Math.max(4, cssH0 + (ev.clientY - startY)) + "px";
};
const onUp = (ev) => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
box.classList.remove("dragging");
const dwPt = (ev.clientX - startX) / state.editScale;
const dhPt = (ev.clientY - startY) / state.editScale; // kéo xuống = cao hơn (y giảm ở đáy)
const sx = Math.max(0.1, (w0 + dwPt) / Math.max(1, w0));
const sy = Math.max(0.1, (h0 + dhPt) / Math.max(1, h0));
if (Math.abs(sx - 1) > 0.01 || Math.abs(sy - 1) > 0.01) {
// Engine scale quanh góc DƯỚI-trái; bù dy để neo góc TRÊN-trái đứng yên
// (khớp handle góc dưới-phải + preview khi kéo, như Foxit).
stageEditOp({ op: "transform", index: o.index, dx: 0, dy: -(h0 * (sy - 1)), sx, sy });
} else {
loadEditPage(); // trả khung về đúng kích thước cũ
}
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
async function armAddImage() {
const path = await invoke("pick_image");
if (!path) return;
state.editPendingImage = path;
state.editArm = "image";
$("edAddImage").classList.add("armed");
$("editOverlay").classList.add("armed");
$("editHint").textContent = "Bấm lên trang để đặt ảnh";
}
async function replaceSelectedImage() {
if (state.editSel == null) return;
const o = state.editObjects.find((x) => x.index === state.editSel);
if (!o || o.kind !== "image") return;
const path = await invoke("pick_image");
if (!path) return;
stageEditOp({ op: "replaceImage", index: o.index, imagePath: path });
}
async function saveEdits() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
// editBase đã gồm mọi thay đổi của trang đang sửa; ghi ra output (ops rỗng = sao chép/lưu lại).
await invoke("edit_apply", { input: state.editBase, page: state.editPage, ops: [], output: out, password: null });
$("status").textContent = `Đã lưu nội dung đã sửa → ${shortName(out)}`;
exitEditMode();
loadDocument(out);
} catch (e) {
$("editHint").textContent = "Lỗi lưu: " + e;
}
}
// ---------- Phase 7: OCR & Chuyển đổi ----------
async function toggleConvMode() {
state.convMode = !state.convMode;
$("convBar").classList.toggle("hidden", !state.convMode);
$("convModeBtn").classList.toggle("active", state.convMode);
if (state.convMode) {
try {
const st = await invoke("convert_tools_status");
const miss = [];
if (!st.tesseract) miss.push("Tesseract (OCR)");
if (!st.soffice) miss.push("LibreOffice (Office↔PDF chất lượng cao)");
$("convHint").textContent = miss.length
? `Chưa cài: ${miss.join(", ")} — các nút liên quan sẽ báo lỗi kèm hướng dẫn`
: "";
$("cvOcr").disabled = !st.tesseract;
$("cvOffice").disabled = !st.soffice;
} catch (_) { /* trạng thái chỉ để gợi ý */ }
} else {
$("convHint").textContent = "";
}
}
async function runOcrAction() {
const lang = $("cvLang").value || "vie+eng";
const out = await invoke("pick_save_pdf");
if (!out) return;
$("convHint").textContent = "Đang OCR… (tài liệu dài có thể mất vài phút)";
try {
const n = await invoke("ocr_run", { input: state.path, lang, output: out });
$("status").textContent = `OCR xong: ${n} từ, đã thêm lớp text ẩn → ${shortName(out)}`;
$("convHint").textContent = "";
loadDocument(out);
} catch (e) {
$("convHint").textContent = "Lỗi OCR: " + e;
}
}
async function exportPngAction() {
const dir = await invoke("pick_dir");
if (!dir) return;
try {
const files = await invoke("convert_images", { input: state.path, outDir: dir, dpi: 150 });
$("status").textContent = `Đã xuất ${files.length} ảnh PNG (150 DPI) vào ${dir}`;
} catch (e) {
$("convHint").textContent = "Lỗi xuất PNG: " + e;
}
}
async function exportTxtAction() {
const base = shortName(state.path).replace(/\.pdf$/i, "");
const out = await invoke("pick_save_as", { ext: "txt", name: base + ".txt" });
if (!out) return;
try {
await invoke("convert_txt", { input: state.path, output: out });
$("status").textContent = `Đã xuất text → ${shortName(out)}`;
} catch (e) {
$("convHint").textContent = "Lỗi xuất TXT: " + e;
}
}
async function exportDocxAction() {
const base = shortName(state.path).replace(/\.pdf$/i, "");
const out = await invoke("pick_save_as", { ext: "docx", name: base + ".docx" });
if (!out) return;
$("convHint").textContent = "Đang chuyển sang Word…";
try {
const engine = await invoke("convert_docx", { input: state.path, output: out });
const note = engine === "libreoffice" ? "LibreOffice (giữ layout tốt)" : "bộ chuyển cơ bản (text + bố cục)";
$("status").textContent = `Đã xuất Word (${note}) → ${shortName(out)}`;
$("convHint").textContent = "";
} catch (e) {
$("convHint").textContent = "Lỗi xuất Word: " + e;
}
}
async function officeToPdfAction() {
const src = await invoke("pick_office_file");
if (!src) return;
const dir = await invoke("pick_dir");
if (!dir) return;
$("convHint").textContent = "Đang chuyển Office → PDF…";
try {
const out = await invoke("office_convert", { input: src, outDir: dir });
$("status").textContent = `Đã chuyển → ${shortName(out)}`;
$("convHint").textContent = "";
loadDocument(out);
} catch (e) {
$("convHint").textContent = "Lỗi Office→PDF (máy cần LibreOffice): " + e;
}
}
// ---------- Phase 6: Form (AcroForm) ----------
function toggleFormMode() {
state.formMode = !state.formMode;
$("formBar").classList.toggle("hidden", !state.formMode);
$("formModeBtn").classList.toggle("active", state.formMode);
$("formHint").textContent = "";
if (state.formMode) refreshFormCount();
}
async function refreshFormCount() {
try {
const fields = await invoke("form_list", { path: state.path });
state.formFields = fields;
$("fmCount").textContent = fields.length;
$("formHint").textContent = fields.length
? `${fields.length} field trong tài liệu`
: "Tài liệu chưa có field form — dùng “Thêm field”.";
} catch (e) {
$("formHint").textContent = "Lỗi đọc form: " + e;
}
}
// Modal liệt kê field + input để điền, nút Lưu.
async function openFillForm() {
let fields;
try {
fields = await invoke("form_list", { path: state.path });
} catch (e) {
$("formHint").textContent = "Lỗi đọc form: " + e;
return;
}
if (!fields.length) {
openModal("Điền form", `Tài liệu chưa có field form nào. Hãy dùng “➕ Thêm field”.
`)
.querySelector("#ffClose").addEventListener("click", closeModal);
return;
}
const rows = fields
.map((f, i) => {
const label = `${escapeHtml(f.name)} (${f.kind}${f.pageIndex != null ? `, trang ${f.pageIndex + 1}` : ""}) `;
let input;
if (f.kind === "checkbox" || f.kind === "radio") {
const on = f.value && f.value !== "Off";
input = ` `;
} else if ((f.kind === "combo" || f.kind === "list") && f.options.length) {
const opts = f.options
.map((o) => `${escapeHtml(o)} `)
.join("");
input = ` ${opts} `;
} else {
input = ` `;
}
return `${label} ${input} `;
})
.join("");
const box = openModal("Điền form", `
`);
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", `
Tên field
Loại
Text (ô nhập chữ)
Checkbox
Combo box (chọn 1)
Lựa chọn (cách nhau dấu phẩy)
`);
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)", `
Mật khẩu mở file (user password)
Nhập lại mật khẩu
Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)
Quyền hạn khi mở bằng mật khẩu user
In
Sửa nội dung
Sao chép text/ảnh
Chú thích/điền form
`);
box.querySelector("#secEncCancel").addEventListener("click", closeModal);
box.querySelector("#secEncOk").addEventListener("click", async () => {
const p1 = box.querySelector("#secPw1").value;
const p2 = box.querySelector("#secPw2").value;
const err = box.querySelector("#secEncErr");
if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; }
if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; }
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_encrypt", {
input: state.path,
output: out,
userPassword: p1,
ownerPassword: box.querySelector("#secPwOwner").value,
allowPrint: box.querySelector("#secPermPrint").checked,
allowModify: box.querySelector("#secPermModify").checked,
allowExtract: box.querySelector("#secPermExtract").checked,
allowAnnotate: box.querySelector("#secPermAnnotate").checked,
});
closeModal();
$("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`;
$("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên.";
} catch (e) {
err.textContent = "Lỗi: " + e;
}
});
}
function openDecryptDialog() {
const box = openModal("Gỡ mật khẩu", `
Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.
Mật khẩu hiện tại
`);
box.querySelector("#secDecCancel").addEventListener("click", closeModal);
box.querySelector("#secDecOk").addEventListener("click", async () => {
const pw = box.querySelector("#secDecPw").value;
const err = box.querySelector("#secDecErr");
if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; }
const inp = await invoke("pick_pdf");
if (!inp) return;
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_decrypt", { input: inp, password: pw, output: out });
closeModal();
$("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
err.textContent = "Lỗi (mật khẩu sai?): " + e;
}
});
}
async function stripMetadataAction() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_strip_metadata", { input: state.path, output: out });
$("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("secHint").textContent = "Lỗi xoá metadata: " + e;
}
}
async function optimizeSaveAction() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_optimize", { input: state.path, output: out });
$("status").textContent = `Đã lưu tối ưu (nén + dọn rác) → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("secHint").textContent = "Lỗi lưu tối ưu: " + e;
}
}
// --- Chữ ký số (Phase 5 iteration 2) ---
function openCreateIdDialog() {
const box = openModal("Tạo Digital ID tự ký", `
Tạo chứng chỉ tự ký (RSA-2048) để ký thử. Nếu có chữ ký của tổ chức phát hành (PFX/PEM) thì dùng thẳng nó ở bước Ký số.
Tên hiển thị trong chữ ký
`);
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", `
Tên người ký (hiện trong chữ ký)
Lý do ký
Digital ID (.pem)
Chọn…
`);
let idPath = null;
box.querySelector("#sgPickId").addEventListener("click", async () => {
const p = await invoke("pick_any_file");
if (p) { idPath = p; box.querySelector("#sgId").value = shortName(p); }
});
box.querySelector("#sgCancel").addEventListener("click", closeModal);
box.querySelector("#sgOk").addEventListener("click", async () => {
const name = box.querySelector("#sgName").value.trim();
const reason = box.querySelector("#sgReason").value.trim();
const err = box.querySelector("#sgErr");
if (!name) { err.textContent = "Cần nhập tên người ký."; return; }
if (!idPath) { err.textContent = "Cần chọn Digital ID (.pem)."; return; }
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("sig_sign", { input: state.path, idPem: idPath, reason, signerName: name, output: out });
closeModal();
$("status").textContent = `Đã ký số → ${shortName(out)}`;
loadDocument(out);
// Tự kiểm tra chữ ký vừa tạo.
verifySignaturesAction(out);
} catch (e) {
err.textContent = "Lỗi ký: " + e;
}
});
}
async function verifySignaturesAction(pathOverride) {
const target = typeof pathOverride === "string" ? pathOverride : state.path;
let checks;
try {
checks = await invoke("sig_verify", { input: target });
} catch (e) {
$("secHint").textContent = "Lỗi kiểm tra: " + e;
return;
}
const rows = checks.length
? checks
.map((c, i) => {
const badge = c.valid
? '✓ Hợp lệ '
: '✗ Không hợp lệ ';
const detail = [];
if (!c.cryptoValid) detail.push("chữ ký sai");
if (!c.digestMatches) detail.push("nội dung đã bị sửa");
if (!c.coversDocument) detail.push("có phần thêm sau khi ký");
return `${i + 1} ${(c.signer || "").replace(/ ${badge}
${detail.join("; ")} `;
})
.join("")
: `Tài liệu chưa có chữ ký số nào. `;
const box = openModal("Kết quả kiểm tra chữ ký", `
# Người ký Trạng thái Ghi chú
${rows}
`);
box.querySelector("#sigClose").addEventListener("click", closeModal);
}
// ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile);
// Kéo-thả file PDF từ File Explorer vào cửa sổ → mở luôn (chuẩn desktop app).
// Tauri v2 bắn sự kiện drag-drop toàn cửa sổ với danh sách đường dẫn thật.
if (window.__TAURI__.event) {
window.__TAURI__.event.listen("tauri://drag-drop", (e) => {
const paths = (e.payload && e.payload.paths) || [];
const pdf = paths.find((p) => /\.pdf$/i.test(p));
if (!pdf) {
if (paths.length) $("status").textContent = "Chỉ mở được file .pdf";
return;
}
if (state.editMode) exitEditMode();
if (state.organizeMode) exitOrganizeMode();
loadDocument(pdf);
});
}
$("zoomIn").addEventListener("click", () => setZoom(state.zoom * 1.25));
$("zoomOut").addEventListener("click", () => setZoom(state.zoom / 1.25));
$("zoomFit").addEventListener("click", fitWidth);
$("zoomFitPage").addEventListener("click", fitPage);
buildZoomSelect();
$("zoomSelect").addEventListener("change", (e) => setZoom(Number(e.target.value) / 100));
$("copyPage").addEventListener("click", copyCurrentPage);
$("pagePrev").addEventListener("click", gotoPrevPage);
$("pageNext").addEventListener("click", gotoNextPage);
$("pageInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); goToPageInput(); }
});
$("pageInput").addEventListener("blur", goToPageInput);
$("searchBox").addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 300);
});
$("searchBox").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); gotoHit(state.hitIdx + (e.shiftKey ? -1 : 1)); }
});
$("searchCase").addEventListener("change", runSearch);
$("searchNext").addEventListener("click", () => gotoHit(state.hitIdx + 1));
$("searchPrev").addEventListener("click", () => gotoHit(state.hitIdx - 1));
$("viewport").addEventListener("scroll", () => {
updateCurrentPage();
if (state.hits.length) drawHighlightsForVisible();
closeColorPopover();
closeNotePopup();
finishEditing();
});
// Ctrl + lăn chuột: zoom tại vị trí con trỏ.
// Gộp nhiều nấc lăn trong 1 khung hình (rAF) để mượt, không dồn render.
let wheelFactor = 1;
let wheelRAF = 0;
let wheelX = 0;
let wheelY = 0;
$("viewport").addEventListener(
"wheel",
(e) => {
if (!e.ctrlKey) return;
e.preventDefault();
wheelFactor *= e.deltaY < 0 ? 1.1 : 1 / 1.1;
wheelX = e.clientX;
wheelY = e.clientY;
if (!wheelRAF) {
wheelRAF = requestAnimationFrame(() => {
wheelRAF = 0;
const f = wheelFactor;
wheelFactor = 1;
zoomAtPoint(state.zoom * f, wheelX, wheelY);
});
}
},
{ passive: false }
);
$("tabThumbs").addEventListener("click", () => switchTab("thumbs"));
$("tabOutline").addEventListener("click", () => switchTab("outline"));
$("tabComments").addEventListener("click", () => switchTab("comments"));
function switchTab(which) {
for (const [tab, panel] of [["tabThumbs", "thumbs"], ["tabOutline", "outline"], ["tabComments", "comments"]]) {
const on = panel === which;
$(tab).classList.toggle("active", on);
$(panel).classList.toggle("hidden", !on);
}
}
// Công cụ chú thích
document.querySelectorAll(".atool").forEach((b) =>
b.addEventListener("click", () => setTool(b.dataset.tool))
);
function refreshColorSwatch() {
const sw = $("annotSw");
if (sw) sw.style.background = rgbCss(state.color);
}
refreshColorSwatch();
$("annotColorBtn").addEventListener("click", () => {
openColorPopover($("annotColorBtn"), state.color, (rgb) => {
state.color = rgb;
refreshColorSwatch();
// áp cho annotation đang chọn (nếu có)
if (state.selectedId != null) {
const s = state.annotSpecs.find((x) => x.id === state.selectedId);
if (s) { pushUndo(); s.color = rgb.slice(); drawAnnotsForPage(s.pageIndex); buildComments(); }
}
});
});
$("saveAnnots").addEventListener("click", saveAnnots);
$("undoBtn").addEventListener("click", () => (state.editMode ? editUndo() : undo()));
$("redoBtn").addEventListener("click", () => (state.editMode ? editRedo() : redo()));
$("pages").addEventListener("mousedown", onPagesMouseDown);
$("pages").addEventListener("dblclick", onViewerDblClick);
window.addEventListener("mousemove", onPagesMouseMove);
window.addEventListener("mouseup", onPagesMouseUp);
$("organizeModeBtn").addEventListener("click", toggleOrganizeMode);
$("orgInsert").addEventListener("click", openInsertDialog);
$("orgDelete").addEventListener("click", orgDeleteSelected);
$("orgRotateL").addEventListener("click", () => orgRotateSelected(-90));
$("orgRotateR").addEventListener("click", () => orgRotateSelected(90));
$("orgExtract").addEventListener("click", openExtractDialog);
$("orgReplace").addEventListener("click", openReplaceDialog);
$("orgMerge").addEventListener("click", openMergeDialog);
$("orgSplit").addEventListener("click", openSplitDialog);
$("orgWatermark").addEventListener("click", openWatermarkDialog);
$("orgHeaderFooter").addEventListener("click", openHeaderFooterDialog);
$("orgSave").addEventListener("click", orgSaveChanges);
$("modalOverlay").addEventListener("click", (e) => {
if (e.target.id === "modalOverlay") closeModal();
});
// OCR & Chuyển đổi (Phase 7)
$("convModeBtn").addEventListener("click", toggleConvMode);
$("cvOcr").addEventListener("click", runOcrAction);
$("cvPng").addEventListener("click", exportPngAction);
$("cvTxt").addEventListener("click", exportTxtAction);
$("cvDocx").addEventListener("click", exportDocxAction);
$("cvOffice").addEventListener("click", officeToPdfAction);
// Form (Phase 6)
$("formModeBtn").addEventListener("click", toggleFormMode);
$("fmFill").addEventListener("click", openFillForm);
$("fmCreate").addEventListener("click", openCreateFieldDialog);
$("fmFlatten").addEventListener("click", flattenFormAction);
$("fmExportFdf").addEventListener("click", () => exportFormData("fdf"));
$("fmExportCsv").addEventListener("click", () => exportFormData("csv"));
$("fmImportFdf").addEventListener("click", importFormFdf);
// Bảo mật (Phase 5)
$("secModeBtn").addEventListener("click", toggleSecMode);
$("secRedactApply").addEventListener("click", applyRedactions);
$("secRedactClear").addEventListener("click", clearRedactMarks);
$("secEncrypt").addEventListener("click", openEncryptDialog);
$("secDecrypt").addEventListener("click", openDecryptDialog);
$("secStripMeta").addEventListener("click", stripMetadataAction);
$("secOptimize").addEventListener("click", optimizeSaveAction);
$("secCreateId").addEventListener("click", openCreateIdDialog);
$("secSign").addEventListener("click", openSignDialog);
$("secVerify").addEventListener("click", () => verifySignaturesAction());
// Sửa nội dung (Phase 4)
$("editModeBtn").addEventListener("click", toggleEditMode);
$("edAddText").addEventListener("click", () => {
state.editArm = state.editArm === "text" ? null : "text";
$("edAddText").classList.toggle("armed", state.editArm === "text");
$("editOverlay").classList.toggle("armed", !!state.editArm);
$("editHint").textContent = state.editArm === "text" ? "Bấm lên trang để đặt chữ" : "";
});
$("edAddImage").addEventListener("click", armAddImage);
$("edDelete").addEventListener("click", deleteSelectedEditObject);
$("edReplaceImage").addEventListener("click", replaceSelectedImage);
// Mọi thay đổi thuộc tính chỉ gửi ĐÚNG field đó — engine giữ nguyên font khi
// không cần thay (chuẩn Foxit: đổi cỡ/màu không được đổi font).
$("edFontSize").addEventListener("change", () => {
const size = Number($("edFontSize").value);
if (size > 0) applyTextPropToSelected({ fontSize: size });
});
$("edFontFamily").addEventListener("change", () => {
const fam = $("edFontFamily").value;
if (fam) applyTextPropToSelected({ fontFamily: fam });
});
$("edBold").addEventListener("click", () => {
const o = state.editObjects.find((x) => x.index === state.editSel);
if (o) applyTextPropToSelected({ bold: !o.fontBold });
});
$("edItalic").addEventListener("click", () => {
const o = state.editObjects.find((x) => x.index === state.editSel);
if (o) applyTextPropToSelected({ italic: !o.fontItalic });
});
$("edColorBtn").addEventListener("click", () => {
openColorPopover($("edColorBtn"), state.editColor, (rgb) => {
state.editColor = rgb;
$("edSw").style.background = rgbCss(rgb);
applyTextPropToSelected({ color: [rgb[0], rgb[1], rgb[2], 255] });
});
});
$("edSave").addEventListener("click", saveEdits);
$("editOverlay").addEventListener("click", onEditStageClick);
// Đúp vào KHE giữa các khung (PDF cắt run rời rạc) vẫn mở ô sửa tại chữ gần đó.
$("editOverlay").addEventListener("dblclick", (e) => {
if (state.editArm) return;
if (e.target.closest && e.target.closest(".edit-inline")) return;
const img = $("editImg");
const r = img.getBoundingClientRect();
const p = state.pages[state.editPage];
const x = (e.clientX - r.left) / state.editScale;
const y = p.heightPt - (e.clientY - r.top) / state.editScale;
const hit = pickTextAt(state.editObjects, x, y);
if (hit) startTextEdit(hit, e);
});
// Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom,
// tìm kiếm, xoá/bỏ chọn annotation.
window.addEventListener("keydown", (e) => {
const typing = e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
if (e.key === "Escape") {
if (!$("modalOverlay").classList.contains("hidden")) { closeModal(); return; }
if (state.editMode && state.editArm) {
state.editArm = null; state.editPendingImage = null;
$("edAddText").classList.remove("armed"); $("edAddImage").classList.remove("armed");
$("editOverlay").classList.remove("armed"); $("editHint").textContent = "";
return;
}
finishEditing(); closeNotePopup(); closeColorPopover(); deselectAnnot(); setTool(null);
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.editMode && state.editSel != null) {
e.preventDefault();
deleteSelectedEditObject();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.organizeMode && state.orgSelected.size) {
e.preventDefault();
orgDeleteSelected();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.selectedId != null) {
e.preventDefault();
deleteSpec(state.selectedId);
return;
}
if (e.ctrlKey && (e.key === "f" || e.key === "F")) {
e.preventDefault();
$("searchBox").focus();
$("searchBox").select();
return;
}
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); setZoom(state.zoom * 1.25); return; }
if (e.ctrlKey && e.key === "-") { e.preventDefault(); setZoom(state.zoom / 1.25); return; }
if (e.ctrlKey && e.key === "0") { e.preventDefault(); setZoom(1); return; }
// Ctrl+Z hoàn tác / Ctrl+Y hoặc Ctrl+Shift+Z làm lại — không chặn khi đang gõ
// trong ô text (để trình duyệt tự xử lý undo cấp ký tự trong contenteditable).
if (e.ctrlKey && !e.shiftKey && (e.key === "z" || e.key === "Z") && !typing) {
e.preventDefault();
if (state.editMode) editUndo(); else undo();
return;
}
if (e.ctrlKey && (e.key === "y" || e.key === "Y" || (e.shiftKey && (e.key === "z" || e.key === "Z"))) && !typing) {
e.preventDefault();
if (state.editMode) editRedo(); else redo();
return;
}
if (typing) return;
if (e.key === "PageDown") { e.preventDefault(); gotoNextPage(); return; }
if (e.key === "PageUp") { e.preventDefault(); gotoPrevPage(); return; }
if (e.key === "Home") { e.preventDefault(); goToPage(0); return; }
if (e.key === "End") { e.preventDefault(); goToPage(state.pages.length - 1); return; }
});
window.addEventListener("DOMContentLoaded", boot);