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 đang chọn
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)
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
};
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 = [];
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();
state.pagePlan = await invoke("organize_identity_plan", { path: state.path, password: null }).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ú"
: 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);
}
}
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 === "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() {
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 = state.current;
state.editBase = state.path;
state.editUndo = [];
state.editRedo = [];
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();
}
function exitEditMode() {
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();
}
// Đọ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();
}
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);
const img = $("editImg");
// Khớp kích thước overlay với ảnh thực tế hiển thị.
state.editObjects.forEach((o) => {
if (o.kind === "path" || o.kind === "shading") return; // path/shading: chưa cho chọn ở v1
const box = document.createElement("div");
box.className = "edit-box kind-" + o.kind + (state.editSel === o.index ? " selected" : "");
box.dataset.index = String(o.index);
Object.assign(box.style, editBoxStyle(o.rect));
box.title = o.kind === "text" ? (o.text || "") : o.kind;
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o));
box.addEventListener("click", (e) => {
if (state.editArm) return; // đang chờ đặt chữ/ảnh → để click rơi xuống overlay
e.stopPropagation();
selectEditObject(o.index);
});
if (o.kind === "text") {
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startInlineTextEdit(o); });
}
ov.appendChild(box);
});
refreshEditSelection();
void img;
}
// 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");
if (isSel && !existing) {
const o = state.editObjects.find((x) => x.index === state.editSel);
const h = document.createElement("div");
h.className = "ed-handle";
h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o));
box.appendChild(h);
} else if (!isSel && existing) {
existing.remove();
}
});
}
function selectEditObject(index) {
state.editSel = index != null && index >= 0 ? index : null;
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;
if (isText) {
$("edFontSize").value = Math.round(o.font_size || 12);
if (o.color) { state.editColor = o.color.slice(0, 3); $("edSw").style.background = rgbCss(state.editColor); }
}
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 op: lưu editBase cũ vào stack undo, materialize ra file tạm mới.
async function stageEditOp(op) {
try {
const out = await invoke("edit_apply_to_temp", {
input: state.editBase, page: state.editPage, ops: [op], password: null,
});
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 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();
}
// Sửa text tại chỗ: ô input phủ lên box, gõ xong Enter/blur → SetText.
function startInlineTextEdit(o) {
const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove();
const inp = document.createElement("input");
inp.className = "edit-inline";
inp.value = o.text || "";
const st = editBoxStyle(o.rect);
Object.assign(inp.style, st);
inp.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px";
ov.appendChild(inp);
inp.focus();
inp.select();
let done = false;
const commit = (save) => {
if (done) return;
done = true;
const text = inp.value;
inp.remove();
if (save && text !== (o.text || "")) {
stageEditOp({
op: "setText",
index: o.index,
text,
fontSize: o.font_size || null,
color: o.color ? o.color : [0, 0, 0, 255],
bold: false,
italic: false,
});
}
};
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));
}
// Đổi cỡ chữ / màu cho text object đang chọn → SetText giữ nguyên nội dung.
function applyTextPropToSelected() {
const o = state.editObjects.find((x) => x.index === state.editSel);
if (!o || o.kind !== "text") return;
const size = Number($("edFontSize").value) || o.font_size || 12;
stageEditOp({
op: "setText",
index: o.index,
text: o.text || "",
fontSize: size,
color: [state.editColor[0], state.editColor[1], state.editColor[2], 255],
bold: false,
italic: false,
});
}
function deleteSelectedEditObject() {
if (state.editSel == null) return;
stageEditOp({ op: "delete", index: state.editSel });
}
// Click lên trang khi đang "armed" để đặt chữ/ảnh mới.
function onEditStageClick(e) {
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];
inp.style.left = pdfX * s + "px";
inp.style.top = (p.heightPt - pdfY) * s + "px";
inp.style.minWidth = "120px";
inp.style.fontSize = Math.max(10, 16 * s) + "px";
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: 16, color: [state.editColor[0], state.editColor[1], state.editColor[2], 255],
bold: false, italic: false,
});
}
};
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 object đang chọn (chuột thật; WebView2 có thể lọc mousemove giả lập).
function onEditBoxMouseDown(e, o) {
if (state.editArm) return;
if (e.target.classList.contains("ed-handle")) return;
selectEditObject(o.index);
const startX = e.clientX, startY = e.clientY;
let moved = false;
const onMove = (ev) => {
if (Math.abs(ev.clientX - startX) > 2 || Math.abs(ev.clientY - startY) > 2) moved = true;
};
const onUp = (ev) => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
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
stageEditOp({ op: "transform", index: o.index, 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.
function onEditResizeMouseDown(e, o) {
e.stopPropagation();
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const w0 = (o.rect.right - o.rect.left), h0 = (o.rect.top - o.rect.bottom);
const onMove = () => {};
const onUp = (ev) => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
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) {
stageEditOp({ op: "transform", index: o.index, dx: 0, dy: 0, sx, sy });
}
};
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;
}
}
// ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile);
$("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);
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();
});
// 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);
$("edFontSize").addEventListener("change", applyTextPropToSelected);
$("edColorBtn").addEventListener("click", () => {
openColorPopover($("edColorBtn"), state.editColor, (rgb) => {
state.editColor = rgb;
$("edSw").style.background = rgbCss(rgb);
applyTextPropToSelected();
});
});
$("edSave").addEventListener("click", saveEdits);
$("editOverlay").addEventListener("click", onEditStageClick);
// 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);