Build Windows portable / build (push) Canceled after 0s
File Canva gói toàn bộ nội dung trang vào 1 Form XObject nên chế độ sửa không thấy gì để sửa. Engine giờ liệt kê PHẲNG đệ quy vào form (FPDFFormObj_CountObjects/GetObject qua pdfium-render), mỗi mục có path [i,j,...] + tích ma trận form để quy toạ độ/cỡ chữ về trang; index của ObjectInfo/EditOp = vị trí trong danh sách phẳng (trang không form thì trùng index trang — tương thích ngược, UI không đổi cách gửi op). - SetText in-place/substitute + ReflowText + Delete hoạt động với run trong form; xoá trong form = set_text chuỗi rỗng (FPDFFormObj_RemoveObject chưa mở ở wrapper cho pdfium_7543), object mới vẽ ở cấp trang với matrix đã compose. - Đã xác minh trước: PDFium regenerate stream form khi con bị sửa (ProcessForm / HasDirtyStreams) — canary test round-trip kèm theo. - ObjectInfo thêm nested/expanded; UI bỏ khung form phủ trang + chặn kéo run trong form (Transform chưa hỗ trợ). - 4 test integration mới với fixture Form XObject tự dựng bằng lopdf (dev-dependency mới); CI thêm bước cargo test -p ff-engine trước build (máy dev không có Rust — CI là nơi duy nhất chạy test), cache thêm target/. Chi tiết: docs/12-phase4-summary.md (Iteration 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3883 lines
152 KiB
JavaScript
3883 lines
152 KiB
JavaScript
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 = `<img alt="t${p.index}" style="height:${thumbH}px"/><div class="n">${p.index + 1}</div>`;
|
||
el.addEventListener("click", () => goToPage(p.index));
|
||
box.appendChild(el);
|
||
thumbObserver.observe(el);
|
||
}
|
||
}
|
||
|
||
function onThumbIntersect(entries) {
|
||
for (const e of entries) {
|
||
if (!e.isIntersecting) continue;
|
||
const el = e.target;
|
||
if (el.dataset.loaded) continue;
|
||
el.dataset.loaded = "1";
|
||
const idx = Number(el.dataset.index);
|
||
invoke("render_page", { path: state.path, page: idx, width: 150 })
|
||
.then((url) => {
|
||
const img = el.querySelector("img");
|
||
img.src = url;
|
||
img.style.height = ""; // trả lại auto theo tỉ lệ
|
||
el.style.minHeight = "";
|
||
})
|
||
.catch(() => { el.dataset.loaded = ""; });
|
||
}
|
||
}
|
||
|
||
// ---------- Outline ----------
|
||
|
||
function buildOutline(items) {
|
||
const box = $("outline");
|
||
box.innerHTML = "";
|
||
if (!items.length) {
|
||
box.innerHTML = `<div class="oitem" style="color:#777">Không có outline</div>`;
|
||
return;
|
||
}
|
||
for (const it of items) {
|
||
const el = document.createElement("div");
|
||
el.className = "oitem";
|
||
el.style.paddingLeft = 6 + it.level * 14 + "px";
|
||
el.textContent = it.title || "(không tiêu đề)";
|
||
if (it.pageIndex != null) {
|
||
el.addEventListener("click", () => goToPage(it.pageIndex));
|
||
} else {
|
||
el.style.color = "#777";
|
||
}
|
||
box.appendChild(el);
|
||
}
|
||
}
|
||
|
||
// ---------- Điều hướng ----------
|
||
|
||
function goToPage(idx) {
|
||
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 = `<span class="sw" style="background:${rgbCss(spec.color)}"></span>A`;
|
||
colorBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||
colorBtn.addEventListener("click", () => {
|
||
openColorPopover(colorBtn, spec.color, (rgb) => {
|
||
spec.color = rgb;
|
||
editorEl.style.color = rgbCss(rgb);
|
||
colorBtn.querySelector(".sw").style.background = rgbCss(rgb);
|
||
editorEl.focus();
|
||
});
|
||
});
|
||
bar.appendChild(colorBtn);
|
||
|
||
document.body.appendChild(bar);
|
||
editing.bar = bar;
|
||
}
|
||
|
||
// ===== Note popup (xem/sửa/đổi màu/xoá) =====
|
||
let notePopupEl = null;
|
||
function openNotePopup(spec) {
|
||
closeNotePopup();
|
||
pushUndo(); // ghi lại trạng thái TRƯỚC khi sửa nội dung/màu ghi chú
|
||
const slot = state.slots[spec.pageIndex];
|
||
const layer = slot.querySelector(".annotlayer");
|
||
const icon = layer.querySelector(`.a-note[data-id="${spec.id}"]`);
|
||
const r = (icon || slot).getBoundingClientRect();
|
||
|
||
const pop = document.createElement("div");
|
||
pop.className = "notepop";
|
||
pop.style.left = r.right + 6 + "px";
|
||
pop.style.top = r.top + "px";
|
||
pop.innerHTML = `
|
||
<div class="np-head"><span>Ghi chú</span><span class="np-close">✕</span></div>
|
||
<textarea class="np-text" placeholder="Nhập nội dung ghi chú…">${escapeHtml(spec.contents || "")}</textarea>
|
||
<div class="np-foot">
|
||
<button class="np-color"><span class="sw" style="background:${rgbCss(spec.color)}"></span>Màu</button>
|
||
<button class="np-del">🗑 Xoá</button>
|
||
</div>`;
|
||
document.body.appendChild(pop);
|
||
notePopupEl = pop;
|
||
|
||
const ta = pop.querySelector(".np-text");
|
||
ta.addEventListener("input", () => {
|
||
spec.contents = ta.value;
|
||
buildComments();
|
||
});
|
||
pop.querySelector(".np-close").addEventListener("click", closeNotePopup);
|
||
pop.querySelector(".np-del").addEventListener("click", () => {
|
||
closeNotePopup();
|
||
deleteSpec(spec.id);
|
||
});
|
||
pop.querySelector(".np-color").addEventListener("click", (e) => {
|
||
openColorPopover(e.currentTarget, spec.color, (rgb) => {
|
||
spec.color = rgb;
|
||
pop.querySelector(".np-color .sw").style.background = rgbCss(rgb);
|
||
drawAnnotsForPage(spec.pageIndex);
|
||
});
|
||
});
|
||
ta.focus();
|
||
}
|
||
function closeNotePopup() {
|
||
if (notePopupEl) { notePopupEl.remove(); notePopupEl = null; }
|
||
}
|
||
|
||
// ===== Color popover (preset + gần đây + tuỳ chọn) =====
|
||
let colorPopEl = null;
|
||
function openColorPopover(anchor, currentRgb, onPick) {
|
||
closeColorPopover();
|
||
const r = anchor.getBoundingClientRect();
|
||
const pop = document.createElement("div");
|
||
pop.className = "colorpop";
|
||
pop.style.left = r.left + "px";
|
||
pop.style.top = r.bottom + 4 + "px";
|
||
|
||
const addSwatch = (rgb, container) => {
|
||
const s = document.createElement("div");
|
||
s.className = "cs";
|
||
s.style.background = rgbCss(rgb);
|
||
if (rgb[0] === currentRgb[0] && rgb[1] === currentRgb[1] && rgb[2] === currentRgb[2]) s.classList.add("cur");
|
||
s.addEventListener("click", () => {
|
||
pushRecentColor(rgb);
|
||
onPick(rgb.slice());
|
||
closeColorPopover();
|
||
});
|
||
container.appendChild(s);
|
||
};
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "cgrid";
|
||
for (const c of PRESET_COLORS) addSwatch(c, grid);
|
||
pop.appendChild(grid);
|
||
|
||
if (state.recentColors.length) {
|
||
const lbl = document.createElement("div");
|
||
lbl.className = "clbl"; lbl.textContent = "Gần đây";
|
||
pop.appendChild(lbl);
|
||
const rg = document.createElement("div");
|
||
rg.className = "cgrid";
|
||
for (const c of state.recentColors) addSwatch(c, rg);
|
||
pop.appendChild(rg);
|
||
}
|
||
|
||
const custom = document.createElement("label");
|
||
custom.className = "ccustom";
|
||
custom.innerHTML = `Tuỳ chọn… <input type="color" value="${rgbToHex(currentRgb)}">`;
|
||
custom.querySelector("input").addEventListener("input", (e) => {
|
||
const rgb = hexToRgb(e.target.value);
|
||
pushRecentColor(rgb);
|
||
onPick(rgb);
|
||
});
|
||
custom.querySelector("input").addEventListener("change", closeColorPopover);
|
||
pop.appendChild(custom);
|
||
|
||
document.body.appendChild(pop);
|
||
colorPopEl = pop;
|
||
}
|
||
function closeColorPopover() {
|
||
if (colorPopEl) { colorPopEl.remove(); colorPopEl = null; }
|
||
}
|
||
function pushRecentColor(rgb) {
|
||
state.recentColors = state.recentColors.filter((c) => !(c[0] === rgb[0] && c[1] === rgb[1] && c[2] === rgb[2]));
|
||
state.recentColors.unshift(rgb.slice());
|
||
state.recentColors = state.recentColors.slice(0, 8);
|
||
}
|
||
function rgbToHex(c) {
|
||
return "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
let drag = null;
|
||
function onPagesMouseDown(e) {
|
||
if (!state.tool) {
|
||
// click vùng trống: bỏ chọn + đóng popup + kết thúc sửa
|
||
if (!e.target.closest(".a-editor")) {
|
||
finishEditing();
|
||
closeNotePopup();
|
||
closeColorPopover();
|
||
if (!e.target.dataset || e.target.dataset.id == null) deselectAnnot();
|
||
}
|
||
return;
|
||
}
|
||
// Highlight/Underline/Strikeout: để trình duyệt tự xử lý chọn text (textlayer
|
||
// pointer-events đã mở qua .tool-mark) — tạo annotation ở mouseup từ Selection.
|
||
if (isTextMarkupTool(state.tool)) return;
|
||
const slot = e.target.closest && e.target.closest(".page-slot");
|
||
if (!slot) return;
|
||
e.preventDefault();
|
||
const r = slot.getBoundingClientRect();
|
||
drag = {
|
||
idx: Number(slot.dataset.index),
|
||
x0: e.clientX - r.left,
|
||
y0: e.clientY - r.top,
|
||
x1: e.clientX - r.left,
|
||
y1: e.clientY - r.top,
|
||
};
|
||
}
|
||
function onPagesMouseMove(e) {
|
||
if (!drag) return;
|
||
const slot = state.slots[drag.idx];
|
||
const r = slot.getBoundingClientRect();
|
||
drag.x1 = e.clientX - r.left;
|
||
drag.y1 = e.clientY - r.top;
|
||
const layer = slot.querySelector(".annotlayer");
|
||
let prev = layer.querySelector(".a-preview");
|
||
if (!prev) {
|
||
prev = document.createElement("div");
|
||
prev.className = "a-hl a-preview";
|
||
layer.appendChild(prev);
|
||
}
|
||
const l = Math.min(drag.x0, drag.x1);
|
||
const t = Math.min(drag.y0, drag.y1);
|
||
prev.style.cssText =
|
||
`left:${l}px;top:${t}px;width:${Math.abs(drag.x1 - drag.x0)}px;height:${Math.abs(drag.y1 - drag.y0)}px;` +
|
||
`background:rgba(${state.color[0]},${state.color[1]},${state.color[2]},.3)`;
|
||
}
|
||
|
||
// Mỗi "từ" trong text layer là 1 <span> định vị tuyệt đối riêng (để bám đúng
|
||
// pixel PDFium) — vì vậy Range.getClientRects() trả về 1 rect/TỪ, không phải
|
||
// 1 rect/DÒNG như với text chảy bình thường. Gộp các rect chồng lấp theo
|
||
// chiều dọc (cùng 1 dòng) thành 1 rect duy nhất, để highlight 1 cụm từ liền
|
||
// mạch chứ không bị đứt từng từ với khoảng trắng hở giữa.
|
||
function mergeRectsIntoLines(rects) {
|
||
const sorted = rects.slice().sort((a, b) => a.top - b.top);
|
||
const lines = [];
|
||
for (const r of sorted) {
|
||
const line = lines.find((l) => {
|
||
const overlap = Math.min(l.bottom, r.bottom) - Math.max(l.top, r.top);
|
||
return overlap > 0.5 * Math.min(l.bottom - l.top, r.bottom - r.top);
|
||
});
|
||
if (line) {
|
||
line.left = Math.min(line.left, r.left);
|
||
line.right = Math.max(line.right, r.right);
|
||
line.top = Math.min(line.top, r.top);
|
||
line.bottom = Math.max(line.bottom, r.bottom);
|
||
} else {
|
||
lines.push({ left: r.left, right: r.right, top: r.top, bottom: r.bottom });
|
||
}
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
// Tạo Highlight/Underline/Strikeout từ vùng TEXT người dùng vừa chọn (Selection
|
||
// API) — bám đúng từng dòng như Foxit, không phải 1 rectangle tự do.
|
||
function createMarkupFromSelection() {
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
||
const range = sel.getRangeAt(0);
|
||
const anchorNode = range.commonAncestorContainer;
|
||
const anchorEl = anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentElement;
|
||
const slot = anchorEl && anchorEl.closest(".page-slot");
|
||
if (!slot) { sel.removeAllRanges(); return; }
|
||
|
||
const idx = Number(slot.dataset.index);
|
||
const slotRect = slot.getBoundingClientRect();
|
||
const rawRects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5);
|
||
sel.removeAllRanges();
|
||
if (!rawRects.length) return;
|
||
const clientRects = mergeRectsIntoLines(rawRects);
|
||
|
||
const quads = clientRects.map((r) => {
|
||
const a = cssToPdf(idx, r.left - slotRect.left, r.top - slotRect.top);
|
||
const b = cssToPdf(idx, r.right - slotRect.left, r.bottom - slotRect.top);
|
||
return {
|
||
left: Math.min(a.x, b.x), right: Math.max(a.x, b.x),
|
||
bottom: Math.min(a.y, b.y), top: Math.max(a.y, b.y),
|
||
};
|
||
});
|
||
const bounds = quads.reduce(
|
||
(acc, q) => acc ? {
|
||
left: Math.min(acc.left, q.left), right: Math.max(acc.right, q.right),
|
||
bottom: Math.min(acc.bottom, q.bottom), top: Math.max(acc.top, q.top),
|
||
} : { ...q },
|
||
null
|
||
);
|
||
|
||
const spec = {
|
||
id: annotIdSeq++,
|
||
kind: state.tool,
|
||
pageIndex: idx,
|
||
left: bounds.left, bottom: bounds.bottom, right: bounds.right, top: bounds.top,
|
||
quads,
|
||
color: [...state.color],
|
||
contents: "",
|
||
fontSize: 14,
|
||
bold: false,
|
||
italic: false,
|
||
underline: false,
|
||
};
|
||
pushUndo();
|
||
state.annotSpecs.push(spec);
|
||
pushRecentColor(state.color);
|
||
// Giữ tool active để tô tiếp liên tục (như Foxit) — bấm lại nút hoặc Esc để tắt.
|
||
drawAnnotsForPage(idx);
|
||
updateAnnotCount();
|
||
buildComments();
|
||
}
|
||
|
||
function onPagesMouseUp() {
|
||
if (state.tool && isTextMarkupTool(state.tool)) {
|
||
createMarkupFromSelection();
|
||
return;
|
||
}
|
||
if (!drag) return;
|
||
const d = drag;
|
||
drag = null;
|
||
const slot = state.slots[d.idx];
|
||
const prev = slot.querySelector(".a-preview");
|
||
if (prev) prev.remove();
|
||
|
||
const a = cssToPdf(d.idx, d.x0, d.y0);
|
||
const b = cssToPdf(d.idx, d.x1, d.y1);
|
||
const rect = {
|
||
left: Math.min(a.x, b.x),
|
||
right: Math.max(a.x, b.x),
|
||
bottom: Math.min(a.y, b.y),
|
||
top: Math.max(a.y, b.y),
|
||
};
|
||
const tool = state.tool;
|
||
const scale = PT_PER_PX * state.zoom;
|
||
const tiny = rect.right - rect.left < 2 || rect.top - rect.bottom < 2;
|
||
|
||
if (tool === "crop") {
|
||
if (tiny) return;
|
||
openCropDialog(d.idx, rect);
|
||
return;
|
||
}
|
||
if (tool === "redact") {
|
||
if (tiny) return;
|
||
state.redactMarks.push({ page: d.idx, rect });
|
||
drawAnnotsForPage(d.idx);
|
||
updateRedactButtons();
|
||
return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction)
|
||
}
|
||
if (tool === "note") {
|
||
const sz = 18 / scale;
|
||
rect.right = rect.left + sz;
|
||
rect.bottom = rect.top - sz;
|
||
} else if (tool === "freetext") {
|
||
if (tiny) {
|
||
rect.right = rect.left + 160 / scale;
|
||
rect.top = rect.bottom + 24 / scale;
|
||
}
|
||
} else if (tiny) {
|
||
return;
|
||
}
|
||
|
||
const spec = {
|
||
id: annotIdSeq++,
|
||
kind: tool,
|
||
pageIndex: d.idx,
|
||
left: rect.left,
|
||
bottom: rect.bottom,
|
||
right: rect.right,
|
||
top: rect.top,
|
||
color: [...state.color],
|
||
contents: "",
|
||
fontSize: 14,
|
||
bold: false,
|
||
italic: false,
|
||
underline: false,
|
||
};
|
||
pushUndo();
|
||
state.annotSpecs.push(spec);
|
||
pushRecentColor(state.color);
|
||
|
||
// FreeText/Note: gõ ngay → về chế độ chọn (như Foxit). Square: giữ tool active
|
||
// để vẽ tiếp liên tục.
|
||
if (tool === "freetext" || tool === "note") setTool(null);
|
||
drawAnnotsForPage(d.idx);
|
||
updateAnnotCount();
|
||
buildComments();
|
||
|
||
if (tool === "freetext") editTextBox(spec);
|
||
else if (tool === "note") openNotePopup(spec);
|
||
}
|
||
|
||
async function saveAnnots() {
|
||
if (!state.annotSpecs.length) return;
|
||
finishEditing();
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
const specs = state.annotSpecs.map((s) => ({
|
||
kind: s.kind,
|
||
pageIndex: s.pageIndex,
|
||
left: s.left, bottom: s.bottom, right: s.right, top: s.top,
|
||
quads: s.quads || [],
|
||
color: [s.color[0], s.color[1], s.color[2], 255],
|
||
contents: s.contents || null,
|
||
fontSize: s.fontSize || 14,
|
||
bold: !!s.bold,
|
||
italic: !!s.italic,
|
||
underline: !!s.underline,
|
||
}));
|
||
await invoke("apply_annotations", { input: state.path, output: out, specs });
|
||
const n = state.annotSpecs.length;
|
||
state.annotSpecs = [];
|
||
updateAnnotCount();
|
||
$("status").textContent = `Đã lưu ${n} chú thích → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("status").textContent = "Lỗi lưu chú thích: " + e;
|
||
}
|
||
}
|
||
|
||
function buildComments() {
|
||
const box = $("comments");
|
||
box.innerHTML = "";
|
||
if (!state.annotSpecs.length) {
|
||
box.innerHTML = `<div class="empty">Chưa có chú thích chưa lưu. Chọn công cụ ở thanh trên và vẽ lên trang, rồi bấm "Lưu chú thích".</div>`;
|
||
return;
|
||
}
|
||
const labels = {
|
||
highlight: "Tô sáng", underline: "Gạch chân", strikeout: "Gạch ngang",
|
||
square: "Khung", freetext: "Text box", note: "Ghi chú",
|
||
};
|
||
for (const s of state.annotSpecs) {
|
||
const el = document.createElement("div");
|
||
el.className = "citem";
|
||
el.innerHTML =
|
||
`<span class="cdel">✕</span>` +
|
||
`<span class="csw" style="background:${rgbCss(s.color)}"></span>` +
|
||
`<span class="ckind">${labels[s.kind] || s.kind}</span> · trang ${s.pageIndex + 1}` +
|
||
(s.contents ? `<br><span class="ctxt">${escapeHtml(s.contents)}</span>` : "");
|
||
el.addEventListener("click", (ev) => {
|
||
if (ev.target.classList.contains("cdel")) {
|
||
deleteSpec(s.id);
|
||
} else {
|
||
goToPage(s.pageIndex);
|
||
selectAnnot(s.id);
|
||
if (s.kind === "note") openNotePopup(s);
|
||
}
|
||
});
|
||
box.appendChild(el);
|
||
}
|
||
}
|
||
|
||
// ---------- Copy ----------
|
||
|
||
async function copyCurrentPage() {
|
||
try {
|
||
const txt = await invoke("page_text", { path: state.path, page: state.current });
|
||
await navigator.clipboard.writeText(txt);
|
||
$("status").textContent = `Đã copy text trang ${state.current + 1} (${txt.length} ký tự)`;
|
||
} catch (e) {
|
||
$("status").textContent = "Lỗi copy: " + e;
|
||
}
|
||
}
|
||
|
||
// ---------- Phase 3: Tổ chức trang ----------
|
||
|
||
function openModal(title, bodyHtml) {
|
||
$("modalBox").innerHTML = `<h3>${title}</h3>${bodyHtml}`;
|
||
$("modalOverlay").classList.remove("hidden");
|
||
return $("modalBox");
|
||
}
|
||
function closeModal() {
|
||
$("modalOverlay").classList.add("hidden");
|
||
$("modalBox").innerHTML = "";
|
||
}
|
||
|
||
/// Parse "1-3,5" (1-based, kiểu người dùng nhập) → mảng index 0-based hợp lệ.
|
||
function parsePageRange(str, count) {
|
||
const out = [];
|
||
for (const part of str.split(",")) {
|
||
const t = part.trim();
|
||
if (!t) continue;
|
||
const m = t.match(/^(\d+)(?:-(\d+))?$/);
|
||
if (!m) continue;
|
||
const a = Number(m[1]);
|
||
const b = m[2] ? Number(m[2]) : a;
|
||
for (let n = Math.min(a, b); n <= Math.max(a, b); n++) {
|
||
if (n >= 1 && n <= count) out.push(n - 1);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function enterOrganizeMode() {
|
||
state.organizeMode = true;
|
||
$("annobar").classList.add("hidden");
|
||
$("organizeBar").classList.remove("hidden");
|
||
$("viewport").classList.add("hidden");
|
||
$("organizeGrid").classList.remove("hidden");
|
||
$("sidebar").classList.add("hidden");
|
||
$("organizeModeBtn").classList.add("active");
|
||
setTool(null);
|
||
buildOrganizeGrid();
|
||
}
|
||
function exitOrganizeMode() {
|
||
state.organizeMode = false;
|
||
$("annobar").classList.remove("hidden");
|
||
$("organizeBar").classList.add("hidden");
|
||
$("viewport").classList.remove("hidden");
|
||
$("organizeGrid").classList.add("hidden");
|
||
$("sidebar").classList.remove("hidden");
|
||
$("organizeModeBtn").classList.remove("active");
|
||
}
|
||
function toggleOrganizeMode() {
|
||
if (state.organizeMode) exitOrganizeMode();
|
||
else enterOrganizeMode();
|
||
}
|
||
|
||
function orgSelectCard(i, e) {
|
||
if (e.shiftKey && state.orgAnchor != null) {
|
||
const a = Math.min(state.orgAnchor, i);
|
||
const b = Math.max(state.orgAnchor, i);
|
||
state.orgSelected = new Set();
|
||
for (let k = a; k <= b; k++) state.orgSelected.add(k);
|
||
} else if (e.ctrlKey || e.metaKey) {
|
||
if (state.orgSelected.has(i)) state.orgSelected.delete(i);
|
||
else state.orgSelected.add(i);
|
||
state.orgAnchor = i;
|
||
} else {
|
||
state.orgSelected = new Set([i]);
|
||
state.orgAnchor = i;
|
||
}
|
||
// Chỉ đổi class — KHÔNG dựng lại lưới (tránh render lại toàn bộ thumbnail).
|
||
refreshOrgSelection();
|
||
}
|
||
|
||
// Chỉ cập nhật trạng thái chọn (viền xanh) trên các card hiện có — không đụng
|
||
// tới thumbnail. Tách khỏi buildOrganizeGrid để click chọn không gây render lại.
|
||
function refreshOrgSelection() {
|
||
const cards = $("organizeGrid").children;
|
||
for (let i = 0; i < cards.length; i++) {
|
||
cards[i].classList.toggle("selected", state.orgSelected.has(i));
|
||
}
|
||
}
|
||
|
||
// pagePlan có khác trạng thái gốc của file đang mở không (đã chèn/xoá/đảo/xoay/
|
||
// crop)? Watermark/Header-Footer cần biết để áp đúng lên trạng thái đang xem,
|
||
// không phải file gốc còn trên đĩa (xem materializeBaseInput).
|
||
function planIsDirty() {
|
||
if (state.pagePlan.length !== state.pages.length) return true;
|
||
return state.pagePlan.some((e, i) =>
|
||
e.kind !== "existing" || e.source || e.srcIndex !== i ||
|
||
(e.rotationDelta || 0) !== 0 || e.crop);
|
||
}
|
||
|
||
// Trả về đường dẫn file nên dùng làm "input" cho Watermark/Header-Footer:
|
||
// nếu pagePlan đang giữ nguyên trạng thái gốc → dùng thẳng state.path; nếu
|
||
// đang có thay đổi tổ chức trang chưa lưu (chèn/xoá/đảo/xoay/crop) → dựng tạm
|
||
// 1 file theo ĐÚNG trạng thái đang xem trên lưới (qua organize_materialize,
|
||
// dùng lại build_document đã có) rồi dùng file đó — để watermark/đánh số thấy
|
||
// đúng những gì người dùng đang thấy, không cần ép họ lưu rồi mở lại.
|
||
async function materializeBaseInput() {
|
||
if (!planIsDirty()) return { path: state.path, isTemp: false, pageCount: state.pagePlan.length };
|
||
const path = await invoke("organize_materialize", {
|
||
mainInput: state.path, plan: state.pagePlan, password: null,
|
||
});
|
||
return { path, isTemp: true, pageCount: state.pagePlan.length };
|
||
}
|
||
const MATERIALIZED_NOTE =
|
||
`<p class="status">Đang dùng bản xem trước đã gồm các thay đổi tổ chức trang ` +
|
||
`chưa lưu (chèn/xoá/đảo/xoay/crop) — chưa ghi đè file gốc.</p>`;
|
||
|
||
function orgMovePage(from, to) {
|
||
if (from === to) return;
|
||
pushUndo();
|
||
const [moved] = state.pagePlan.splice(from, 1);
|
||
state.pagePlan.splice(to, 0, moved);
|
||
state.orgSelected = new Set();
|
||
state.orgAnchor = null;
|
||
buildOrganizeGrid();
|
||
}
|
||
|
||
// Khoá cache thumbnail: 1 ảnh render chỉ phụ thuộc nguồn + trang gốc (rotation
|
||
// áp bằng CSS transform, crop chỉ là dấu chấm — đều không cần render lại).
|
||
function orgThumbKey(entry) {
|
||
return `${entry.source || ""}#${entry.srcIndex}`;
|
||
}
|
||
|
||
// Dựng lại toàn bộ lưới (chỉ gọi khi CẤU TRÚC plan đổi: chèn/xoá/đảo). Thumbnail
|
||
// lấy từ cache nếu có; chỉ render trang chưa từng render → click chọn/xoay không
|
||
// còn kéo theo render lại toàn bộ.
|
||
function buildOrganizeGrid() {
|
||
const box = $("organizeGrid");
|
||
box.innerHTML = "";
|
||
state.pagePlan.forEach((entry, i) => {
|
||
const dims = (!entry.source && state.pages[entry.srcIndex])
|
||
? state.pages[entry.srcIndex]
|
||
: { widthPt: entry.widthPt || 612, heightPt: entry.heightPt || 792 };
|
||
const card = document.createElement("div");
|
||
card.className = "org-card" + (state.orgSelected.has(i) ? " selected" : "");
|
||
card.draggable = true;
|
||
card.dataset.index = String(i);
|
||
const wrapH = Math.round(160 * (dims.heightPt / dims.widthPt));
|
||
card.innerHTML =
|
||
`<div class="org-thumb-wrap" style="height:${wrapH}px">` +
|
||
(entry.kind === "blank"
|
||
? `<div style="width:100%;height:100%;background:#fff"></div>`
|
||
: `<img alt="p${i}"/>`) +
|
||
(entry.crop ? `<div class="org-crop-mark"></div>` : "") +
|
||
`</div><div class="org-n">${i + 1}</div>`;
|
||
const img = card.querySelector("img");
|
||
if (img) {
|
||
const rot = entry.rotationDelta || 0;
|
||
if (rot) img.style.transform = `rotate(${rot}deg)`;
|
||
const key = orgThumbKey(entry);
|
||
const cached = state.orgThumbs.get(key);
|
||
if (cached) {
|
||
img.src = cached;
|
||
} else {
|
||
const path = entry.source || state.path;
|
||
invoke("render_page", { path, page: entry.srcIndex, width: 160 })
|
||
.then((url) => { state.orgThumbs.set(key, url); img.src = url; })
|
||
.catch(() => {});
|
||
}
|
||
}
|
||
card.addEventListener("click", (e) => orgSelectCard(i, e));
|
||
card.addEventListener("dragstart", (e) => e.dataTransfer.setData("text/plain", String(i)));
|
||
card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("drag-over"); });
|
||
card.addEventListener("dragleave", () => card.classList.remove("drag-over"));
|
||
card.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
card.classList.remove("drag-over");
|
||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||
orgMovePage(from, i);
|
||
});
|
||
box.appendChild(card);
|
||
});
|
||
$("orgSave").disabled = state.pagePlan.length === 0;
|
||
}
|
||
|
||
function orgDeleteSelected() {
|
||
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để xoá"; return; }
|
||
if (state.orgSelected.size >= state.pagePlan.length) { $("organizeHint").textContent = "Không thể xoá hết toàn bộ trang"; return; }
|
||
pushUndo();
|
||
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
|
||
state.orgSelected = new Set();
|
||
$("organizeHint").textContent = "";
|
||
buildOrganizeGrid();
|
||
}
|
||
|
||
function orgRotateSelected(delta) {
|
||
if (!state.pagePlan.length) return;
|
||
pushUndo();
|
||
const targets = state.orgSelected.size ? state.orgSelected : new Set(state.pagePlan.map((_, i) => i));
|
||
const cards = $("organizeGrid").children;
|
||
for (const i of targets) {
|
||
const entry = state.pagePlan[i];
|
||
entry.rotationDelta = ((entry.rotationDelta || 0) + delta + 360) % 360;
|
||
// Chỉ xoay ảnh bằng CSS — KHÔNG render lại trang.
|
||
const img = cards[i] && cards[i].querySelector("img");
|
||
if (img) img.style.transform = entry.rotationDelta ? `rotate(${entry.rotationDelta}deg)` : "";
|
||
}
|
||
}
|
||
|
||
async function orgSaveChanges() {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("organize_apply", { mainInput: state.path, plan: state.pagePlan, output: out, password: null });
|
||
$("status").textContent = `Đã lưu thay đổi tổ chức trang → ${shortName(out)}`;
|
||
exitOrganizeMode();
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("organizeHint").textContent = "Lỗi lưu: " + e;
|
||
}
|
||
}
|
||
|
||
function openInsertDialog() {
|
||
const box = openModal("Chèn trang", `
|
||
<div class="radiorow">
|
||
<label><input type="radio" name="insKind" value="blank" checked> Trang trắng</label>
|
||
<label><input type="radio" name="insKind" value="file"> Từ file…</label>
|
||
</div>
|
||
<div id="insBlankOpts">
|
||
<label>Cỡ giấy</label>
|
||
<select id="insPaper">
|
||
<option value="612x792">Letter</option>
|
||
<option value="595x842">A4</option>
|
||
<option value="custom">Tuỳ chọn…</option>
|
||
</select>
|
||
<div class="row" id="insCustomSize" style="display:none">
|
||
<div><label>Rộng (pt)</label><input type="number" id="insW" value="612"></div>
|
||
<div><label>Cao (pt)</label><input type="number" id="insH" value="792"></div>
|
||
</div>
|
||
</div>
|
||
<div id="insFileOpts" style="display:none">
|
||
<label>File nguồn</label>
|
||
<button id="insPickFile" type="button">📂 Chọn file…</button>
|
||
<span id="insFileName" class="status"></span>
|
||
<label>Trang (vd 1-3,5 — rỗng = tất cả)</label>
|
||
<input type="text" id="insRange" placeholder="tất cả">
|
||
</div>
|
||
<label>Vị trí</label>
|
||
<div class="radiorow">
|
||
<label><input type="radio" name="insPos" value="before" ${state.orgSelected.size ? "" : "disabled"}> Trước trang đang chọn</label>
|
||
<label><input type="radio" name="insPos" value="after" ${state.orgSelected.size ? "checked" : "disabled"}> Sau trang đang chọn</label>
|
||
<label><input type="radio" name="insPos" value="end" ${state.orgSelected.size ? "" : "checked"}> Cuối tài liệu</label>
|
||
</div>
|
||
<div class="err" id="insErr"></div>
|
||
<div class="foot"><button id="insCancel">Huỷ</button><button id="insOk" class="primary">Chèn</button></div>
|
||
`);
|
||
let insertFile = null;
|
||
box.querySelectorAll('input[name=insKind]').forEach((r) => r.addEventListener("change", () => {
|
||
const isFile = box.querySelector('input[name=insKind]:checked').value === "file";
|
||
box.querySelector("#insBlankOpts").style.display = isFile ? "none" : "";
|
||
box.querySelector("#insFileOpts").style.display = isFile ? "" : "none";
|
||
}));
|
||
box.querySelector("#insPaper").addEventListener("change", (e) => {
|
||
box.querySelector("#insCustomSize").style.display = e.target.value === "custom" ? "flex" : "none";
|
||
});
|
||
box.querySelector("#insPickFile").addEventListener("click", async () => {
|
||
const p = await invoke("pick_pdf");
|
||
if (p) { insertFile = p; box.querySelector("#insFileName").textContent = shortName(p); }
|
||
});
|
||
box.querySelector("#insCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#insOk").addEventListener("click", async () => {
|
||
const kind = box.querySelector('input[name=insKind]:checked').value;
|
||
const pos = box.querySelector('input[name=insPos]:checked').value;
|
||
let newEntries = [];
|
||
if (kind === "blank") {
|
||
let w = 612, h = 792;
|
||
const paper = box.querySelector("#insPaper").value;
|
||
if (paper === "custom") {
|
||
w = Number(box.querySelector("#insW").value) || 612;
|
||
h = Number(box.querySelector("#insH").value) || 792;
|
||
} else {
|
||
[w, h] = paper.split("x").map(Number);
|
||
}
|
||
newEntries = [{ kind: "blank", widthPt: w, heightPt: h, rotationDelta: 0, crop: null }];
|
||
} else {
|
||
if (!insertFile) { box.querySelector("#insErr").textContent = "Hãy chọn file nguồn."; return; }
|
||
let count;
|
||
try {
|
||
const meta = await invoke("open_document", { path: insertFile });
|
||
count = meta.pageCount;
|
||
} catch (e) {
|
||
box.querySelector("#insErr").textContent = "Không mở được file: " + e;
|
||
return;
|
||
}
|
||
const rangeStr = box.querySelector("#insRange").value.trim();
|
||
const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i);
|
||
if (!indices.length) { box.querySelector("#insErr").textContent = "Phạm vi trang không hợp lệ."; return; }
|
||
newEntries = indices.map((idx) => ({ kind: "existing", source: insertFile, srcIndex: idx, rotationDelta: 0, crop: null }));
|
||
}
|
||
let at = state.pagePlan.length;
|
||
if (pos === "before") at = Math.min(...state.orgSelected);
|
||
else if (pos === "after") at = Math.max(...state.orgSelected) + 1;
|
||
pushUndo();
|
||
state.pagePlan.splice(at, 0, ...newEntries);
|
||
state.orgSelected = new Set();
|
||
buildOrganizeGrid();
|
||
closeModal();
|
||
});
|
||
}
|
||
|
||
function openExtractDialog() {
|
||
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để trích"; return; }
|
||
const box = openModal("Trích trang", `
|
||
<p>Trích ${state.orgSelected.size} trang đã chọn ra file PDF mới.</p>
|
||
<label><input type="checkbox" id="extDeleteAfter"> Xoá các trang này khỏi tài liệu sau khi trích</label>
|
||
<div class="err" id="extErr"></div>
|
||
<div class="foot"><button id="extCancel">Huỷ</button><button id="extOk" class="primary">Trích…</button></div>
|
||
`);
|
||
box.querySelector("#extCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#extOk").addEventListener("click", async () => {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
const indices = Array.from(state.orgSelected).sort((a, b) => a - b);
|
||
const plan = indices.map((i) => state.pagePlan[i]);
|
||
try {
|
||
await invoke("organize_apply", { mainInput: state.path, plan, output: out, password: null });
|
||
if (box.querySelector("#extDeleteAfter").checked) {
|
||
pushUndo();
|
||
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
|
||
state.orgSelected = new Set();
|
||
buildOrganizeGrid();
|
||
}
|
||
$("organizeHint").textContent = `Đã trích ra ${shortName(out)}`;
|
||
closeModal();
|
||
} catch (e) {
|
||
box.querySelector("#extErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
function openReplaceDialog() {
|
||
if (!state.orgSelected.size) { $("organizeHint").textContent = "Chưa chọn trang nào để thay"; return; }
|
||
const box = openModal("Thay trang", `
|
||
<p>Thay nội dung ${state.orgSelected.size} trang đã chọn bằng trang từ file khác.</p>
|
||
<label>File nguồn</label>
|
||
<button id="repPickFile" type="button">📂 Chọn file…</button>
|
||
<span id="repFileName" class="status"></span>
|
||
<label>Trang nguồn (vd 1-3,5 — rỗng = tất cả)</label>
|
||
<input type="text" id="repRange" placeholder="tất cả">
|
||
<div class="err" id="repErr"></div>
|
||
<div class="foot"><button id="repCancel">Huỷ</button><button id="repOk" class="primary">Thay</button></div>
|
||
`);
|
||
let file = null;
|
||
box.querySelector("#repPickFile").addEventListener("click", async () => {
|
||
const p = await invoke("pick_pdf");
|
||
if (p) { file = p; box.querySelector("#repFileName").textContent = shortName(p); }
|
||
});
|
||
box.querySelector("#repCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#repOk").addEventListener("click", async () => {
|
||
if (!file) { box.querySelector("#repErr").textContent = "Hãy chọn file nguồn."; return; }
|
||
let count;
|
||
try {
|
||
const meta = await invoke("open_document", { path: file });
|
||
count = meta.pageCount;
|
||
} catch (e) {
|
||
box.querySelector("#repErr").textContent = "Không mở được file: " + e;
|
||
return;
|
||
}
|
||
const rangeStr = box.querySelector("#repRange").value.trim();
|
||
const indices = rangeStr ? parsePageRange(rangeStr, count) : Array.from({ length: count }, (_, i) => i);
|
||
if (!indices.length) { box.querySelector("#repErr").textContent = "Phạm vi trang không hợp lệ."; return; }
|
||
const newEntries = indices.map((idx) => ({ kind: "existing", source: file, srcIndex: idx, rotationDelta: 0, crop: null }));
|
||
const sorted = Array.from(state.orgSelected).sort((a, b) => a - b);
|
||
const at = sorted[0];
|
||
pushUndo();
|
||
state.pagePlan = state.pagePlan.filter((_, i) => !state.orgSelected.has(i));
|
||
state.pagePlan.splice(Math.min(at, state.pagePlan.length), 0, ...newEntries);
|
||
state.orgSelected = new Set();
|
||
buildOrganizeGrid();
|
||
closeModal();
|
||
});
|
||
}
|
||
|
||
function openMergeDialog() {
|
||
let files = [];
|
||
const box = openModal("Trộn file PDF", `
|
||
<button id="mrgAdd" type="button">➕ Thêm file…</button>
|
||
<div id="mrgList" style="margin-top:8px;"></div>
|
||
<div class="err" id="mrgErr"></div>
|
||
<div class="foot"><button id="mrgCancel">Huỷ</button><button id="mrgOk" class="primary">Trộn…</button></div>
|
||
`);
|
||
function renderList() {
|
||
box.querySelector("#mrgList").innerHTML = files.map((f, i) =>
|
||
`<div class="row" style="margin-bottom:4px;align-items:center;">` +
|
||
`<span style="flex:3">${i + 1}. ${shortName(f)}</span>` +
|
||
`<button data-up="${i}" type="button" ${i === 0 ? "disabled" : ""}>↑</button>` +
|
||
`<button data-down="${i}" type="button" ${i === files.length - 1 ? "disabled" : ""}>↓</button>` +
|
||
`<button data-rm="${i}" type="button">✕</button></div>`
|
||
).join("");
|
||
}
|
||
box.querySelector("#mrgAdd").addEventListener("click", async () => {
|
||
const p = await invoke("pick_pdf");
|
||
if (p) { files.push(p); renderList(); }
|
||
});
|
||
box.querySelector("#mrgList").addEventListener("click", (e) => {
|
||
const t = e.target;
|
||
if (t.dataset.up != null) {
|
||
const i = Number(t.dataset.up);
|
||
[files[i - 1], files[i]] = [files[i], files[i - 1]];
|
||
renderList();
|
||
} else if (t.dataset.down != null) {
|
||
const i = Number(t.dataset.down);
|
||
[files[i + 1], files[i]] = [files[i], files[i + 1]];
|
||
renderList();
|
||
} else if (t.dataset.rm != null) {
|
||
files.splice(Number(t.dataset.rm), 1);
|
||
renderList();
|
||
}
|
||
});
|
||
box.querySelector("#mrgCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#mrgOk").addEventListener("click", async () => {
|
||
if (files.length < 1) { box.querySelector("#mrgErr").textContent = "Hãy thêm ít nhất 1 file."; return; }
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("organize_merge", { files, output: out });
|
||
$("status").textContent = `Đã trộn ${files.length} file → ${shortName(out)}`;
|
||
closeModal();
|
||
} catch (e) {
|
||
box.querySelector("#mrgErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
renderList();
|
||
}
|
||
|
||
function openSplitDialog() {
|
||
const box = openModal("Tách file PDF", `
|
||
<label>Mỗi file tối đa (số trang)</label>
|
||
<input type="number" id="splitN" value="1" min="1">
|
||
<div class="err" id="splitErr"></div>
|
||
<div class="foot"><button id="splitCancel">Huỷ</button><button id="splitOk" class="primary">Tách…</button></div>
|
||
`);
|
||
box.querySelector("#splitCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#splitOk").addEventListener("click", async () => {
|
||
const n = Math.max(1, Number(box.querySelector("#splitN").value) || 1);
|
||
const outDir = await invoke("pick_dir");
|
||
if (!outDir) return;
|
||
try {
|
||
const base = shortName(state.path).replace(/\.pdf$/i, "");
|
||
const outs = await invoke("organize_split", {
|
||
input: state.path, pagesPerFile: n, outDir, baseName: base, password: null,
|
||
});
|
||
$("status").textContent = `Đã tách thành ${outs.length} file vào ${outDir}`;
|
||
closeModal();
|
||
} catch (e) {
|
||
box.querySelector("#splitErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openWatermarkDialog() {
|
||
const base = await materializeBaseInput();
|
||
const previewPage = base.isTemp ? (state.orgSelected.size ? Math.min(...state.orgSelected) : 0) : state.current;
|
||
const box = openModal("Watermark", `
|
||
${base.isTemp ? MATERIALIZED_NOTE : ""}
|
||
<label>Nội dung</label>
|
||
<input type="text" id="wmText" value="CONFIDENTIAL">
|
||
<div class="row">
|
||
<div><label>Cỡ chữ</label><input type="number" id="wmSize" value="36"></div>
|
||
<div><label>Màu (r,g,b)</label><input type="text" id="wmColor" value="200,0,0"></div>
|
||
<div><label>Độ mờ (0-255)</label><input type="number" id="wmAlpha" value="120" min="0" max="255"></div>
|
||
</div>
|
||
<div class="row">
|
||
<label><input type="checkbox" id="wmBold"> Đậm</label>
|
||
<label><input type="checkbox" id="wmItalic"> Nghiêng</label>
|
||
</div>
|
||
<label>Góc xoay (độ)</label>
|
||
<input type="number" id="wmRotate" value="45">
|
||
<label>Vị trí</label>
|
||
<div class="anchor9" id="wmAnchor">
|
||
${["top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right"]
|
||
.map((a) => `<button type="button" data-a="${a}" class="${a === "center" ? "cur" : ""}">●</button>`).join("")}
|
||
</div>
|
||
<label>Trang áp dụng (rỗng = tất cả, vd 1-3,5)</label>
|
||
<input type="text" id="wmPages" placeholder="tất cả">
|
||
<div class="foot">
|
||
<button id="wmPreview" type="button">👁 Xem trước</button>
|
||
<button id="wmCancel">Huỷ</button><button id="wmOk" class="primary">Áp dụng</button>
|
||
</div>
|
||
<div class="err" id="wmErr"></div>
|
||
`);
|
||
let anchor = "center";
|
||
box.querySelector("#wmAnchor").addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button[data-a]");
|
||
if (!btn) return;
|
||
anchor = btn.dataset.a;
|
||
box.querySelectorAll("#wmAnchor button").forEach((b) => b.classList.toggle("cur", b === btn));
|
||
});
|
||
function buildSpec() {
|
||
const [r, g, b] = box.querySelector("#wmColor").value.split(",").map((x) => Number(x.trim()) || 0);
|
||
return {
|
||
text: box.querySelector("#wmText").value || "",
|
||
fontSize: Number(box.querySelector("#wmSize").value) || 36,
|
||
color: [r, g, b, Number(box.querySelector("#wmAlpha").value) || 120],
|
||
bold: box.querySelector("#wmBold").checked,
|
||
italic: box.querySelector("#wmItalic").checked,
|
||
rotationDeg: Number(box.querySelector("#wmRotate").value) || 0,
|
||
anchor,
|
||
pages: box.querySelector("#wmPages").value.trim()
|
||
? parsePageRange(box.querySelector("#wmPages").value.trim(), base.pageCount)
|
||
: [],
|
||
};
|
||
}
|
||
box.querySelector("#wmPreview").addEventListener("click", async () => {
|
||
try {
|
||
const url = await invoke("preview_watermark", { input: base.path, page: previewPage, spec: buildSpec(), width: 500 });
|
||
let img = box.querySelector("#wmPreviewImg");
|
||
if (!img) {
|
||
img = document.createElement("img");
|
||
img.id = "wmPreviewImg";
|
||
img.style.maxWidth = "100%";
|
||
img.style.marginTop = "8px";
|
||
box.insertBefore(img, box.querySelector(".foot"));
|
||
}
|
||
img.src = url;
|
||
} catch (e) {
|
||
box.querySelector("#wmErr").textContent = "Lỗi xem trước: " + e;
|
||
}
|
||
});
|
||
box.querySelector("#wmCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#wmOk").addEventListener("click", async () => {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("watermark_add", { input: base.path, spec: buildSpec(), output: out, password: null });
|
||
$("status").textContent = `Đã thêm watermark → ${shortName(out)}`;
|
||
closeModal();
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
box.querySelector("#wmErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openHeaderFooterDialog() {
|
||
const base = await materializeBaseInput();
|
||
const previewPage = base.isTemp ? (state.orgSelected.size ? Math.min(...state.orgSelected) : 0) : state.current;
|
||
const box = openModal("Header / Footer", `
|
||
${base.isTemp ? MATERIALIZED_NOTE : ""}
|
||
<div class="row">
|
||
<div><label>Trên-trái</label><input type="text" id="hfTL"></div>
|
||
<div><label>Trên-giữa</label><input type="text" id="hfTC"></div>
|
||
<div><label>Trên-phải</label><input type="text" id="hfTR"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div><label>Dưới-trái</label><input type="text" id="hfBL"></div>
|
||
<div><label>Dưới-giữa</label><input type="text" id="hfBC" value="Trang {page}/{total}"></div>
|
||
<div><label>Dưới-phải</label><input type="text" id="hfBR"></div>
|
||
</div>
|
||
<p class="status">Chèn vào ô đang focus:
|
||
<button type="button" data-tok="{page}">{page}</button>
|
||
<button type="button" data-tok="{total}">{total}</button>
|
||
<button type="button" data-tok="{date}">{date}</button>
|
||
</p>
|
||
<div class="row">
|
||
<div><label>Cỡ chữ</label><input type="number" id="hfSize" value="10"></div>
|
||
<div><label>Lề (pt)</label><input type="number" id="hfMargin" value="20"></div>
|
||
<div><label>Màu (r,g,b)</label><input type="text" id="hfColor" value="0,0,0"></div>
|
||
</div>
|
||
<label>Trang áp dụng (rỗng = tất cả)</label>
|
||
<input type="text" id="hfPages" placeholder="tất cả">
|
||
<div class="foot">
|
||
<button id="hfPreview" type="button">👁 Xem trước</button>
|
||
<button id="hfCancel">Huỷ</button><button id="hfOk" class="primary">Áp dụng</button>
|
||
</div>
|
||
<div class="err" id="hfErr"></div>
|
||
`);
|
||
let lastFocused = box.querySelector("#hfBC");
|
||
box.querySelectorAll("input[type=text]").forEach((inp) => inp.addEventListener("focus", () => { lastFocused = inp; }));
|
||
box.querySelectorAll("button[data-tok]").forEach((btn) => btn.addEventListener("click", () => {
|
||
if (lastFocused) { lastFocused.value += btn.dataset.tok; lastFocused.focus(); }
|
||
}));
|
||
function buildSpec() {
|
||
const [r, g, b] = box.querySelector("#hfColor").value.split(",").map((x) => Number(x.trim()) || 0);
|
||
return {
|
||
topLeft: box.querySelector("#hfTL").value,
|
||
topCenter: box.querySelector("#hfTC").value,
|
||
topRight: box.querySelector("#hfTR").value,
|
||
bottomLeft: box.querySelector("#hfBL").value,
|
||
bottomCenter: box.querySelector("#hfBC").value,
|
||
bottomRight: box.querySelector("#hfBR").value,
|
||
fontSize: Number(box.querySelector("#hfSize").value) || 10,
|
||
color: [r, g, b, 255],
|
||
marginPt: Number(box.querySelector("#hfMargin").value) || 20,
|
||
date: new Date().toLocaleDateString("vi-VN"),
|
||
pages: box.querySelector("#hfPages").value.trim()
|
||
? parsePageRange(box.querySelector("#hfPages").value.trim(), base.pageCount)
|
||
: [],
|
||
};
|
||
}
|
||
box.querySelector("#hfPreview").addEventListener("click", async () => {
|
||
try {
|
||
const url = await invoke("preview_header_footer", { input: base.path, page: previewPage, spec: buildSpec(), width: 500 });
|
||
let img = box.querySelector("#hfPreviewImg");
|
||
if (!img) {
|
||
img = document.createElement("img");
|
||
img.id = "hfPreviewImg";
|
||
img.style.maxWidth = "100%";
|
||
img.style.marginTop = "8px";
|
||
box.insertBefore(img, box.querySelector(".foot"));
|
||
}
|
||
img.src = url;
|
||
} catch (e) {
|
||
box.querySelector("#hfErr").textContent = "Lỗi xem trước: " + e;
|
||
}
|
||
});
|
||
box.querySelector("#hfCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#hfOk").addEventListener("click", async () => {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("header_footer_add", { input: base.path, spec: buildSpec(), output: out, password: null });
|
||
$("status").textContent = `Đã thêm header/footer → ${shortName(out)}`;
|
||
closeModal();
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
box.querySelector("#hfErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Crop: tái dùng cơ chế kéo-vẽ hình chữ nhật đã có cho tool "square" — xem
|
||
// nhánh `tool === "crop"` trong onPagesMouseUp. Tìm theo `srcIndex` (không
|
||
// theo vị trí trong pagePlan) vì viewer luôn hiển thị trang theo số trang GỐC
|
||
// của file đang mở, bất kể pagePlan đã bị đảo/chèn/xoá hay chưa.
|
||
function openCropDialog(pageIdx, rectPdf) {
|
||
const p = state.pages[pageIdx];
|
||
const m = {
|
||
left: rectPdf.left,
|
||
bottom: rectPdf.bottom,
|
||
right: p.widthPt - rectPdf.right,
|
||
top: p.heightPt - rectPdf.top,
|
||
};
|
||
const box = openModal("Crop trang", `
|
||
<div class="row">
|
||
<div><label>Trái (pt)</label><input type="number" id="cropL" value="${m.left.toFixed(1)}"></div>
|
||
<div><label>Phải (pt)</label><input type="number" id="cropR" value="${m.right.toFixed(1)}"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div><label>Trên (pt)</label><input type="number" id="cropT" value="${m.top.toFixed(1)}"></div>
|
||
<div><label>Dưới (pt)</label><input type="number" id="cropB" value="${m.bottom.toFixed(1)}"></div>
|
||
</div>
|
||
<div class="radiorow">
|
||
<label><input type="radio" name="cropScope" value="this" checked> Trang này</label>
|
||
<label><input type="radio" name="cropScope" value="all"> Tất cả trang</label>
|
||
</div>
|
||
<p class="status">Áp dụng ngay vào kế hoạch tổ chức trang — vào "🗂 Tổ chức trang" → "💾 Lưu thay đổi" để ghi ra file thật.</p>
|
||
<div class="foot"><button id="cropCancel">Huỷ</button><button id="cropOk" class="primary">Áp dụng</button></div>
|
||
`);
|
||
box.querySelector("#cropCancel").addEventListener("click", () => {
|
||
closeModal();
|
||
drawAnnotsForPage(pageIdx);
|
||
});
|
||
box.querySelector("#cropOk").addEventListener("click", () => {
|
||
const l = Number(box.querySelector("#cropL").value) || 0;
|
||
const r = Number(box.querySelector("#cropR").value) || 0;
|
||
const t = Number(box.querySelector("#cropT").value) || 0;
|
||
const b = Number(box.querySelector("#cropB").value) || 0;
|
||
const scope = box.querySelector('input[name=cropScope]:checked').value;
|
||
pushUndo();
|
||
for (const entry of state.pagePlan) {
|
||
const isThis = !entry.source && entry.srcIndex === pageIdx;
|
||
if (scope === "all" || isThis) {
|
||
const dims = (!entry.source && state.pages[entry.srcIndex]) ? state.pages[entry.srcIndex] : p;
|
||
entry.crop = { left: l, bottom: b, right: dims.widthPt - r, top: dims.heightPt - t };
|
||
}
|
||
}
|
||
closeModal();
|
||
setTool(null);
|
||
drawAnnotsForPage(pageIdx);
|
||
$("status").textContent = "Đã đặt vùng crop — vào Tổ chức trang > Lưu để ghi ra file thật.";
|
||
});
|
||
}
|
||
|
||
// ---------- Phase 4: Sửa nội dung (Edit) ----------
|
||
// Mô hình "materialize tức thì": mỗi thao tác (sửa text / xoá / thêm / di
|
||
// chuyển) được áp NGAY vào 1 file tạm mới (editBase) qua edit_apply, rồi đọc
|
||
// lại object + render ảnh từ file đó. Nhờ vậy: index object luôn khớp ảnh đang
|
||
// hiện (WYSIWYG thật), không phải tự suy đoán; undo = quay lại file tạm trước.
|
||
|
||
const EDIT_STAGE_W = 820; // px bề rộng ảnh trang khi sửa
|
||
|
||
function enterEditMode(pageIndex) {
|
||
if (!state.path) { $("status").textContent = "Hãy mở file trước khi sửa nội dung"; return; }
|
||
if (state.organizeMode) exitOrganizeMode();
|
||
state.editMode = true;
|
||
state.editPage = Number.isInteger(pageIndex) ? pageIndex : state.current;
|
||
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: <family gốc>)" — chỉ đổi khi người dùng chọn khác.
|
||
$("edFontFamily").options[0].textContent = o.fontFamily
|
||
? `(giữ nguyên: ${o.fontFamily})`
|
||
: "(giữ nguyên)";
|
||
$("edFontFamily").value = "";
|
||
$("edBold").classList.toggle("on", !!o.fontBold);
|
||
$("edItalic").classList.toggle("on", !!o.fontItalic);
|
||
const emb = o.fontEmbedded == null ? "" : o.fontEmbedded ? " · font nhúng" : " · font hệ thống";
|
||
$("editHint").textContent =
|
||
`${o.fontFamily || o.fontName || "?"} · ${Math.round(o.fontSize || 0)}pt${emb}`;
|
||
} else {
|
||
$("edFontFamily").options[0].textContent = "(giữ nguyên)";
|
||
$("edBold").classList.remove("on");
|
||
$("edItalic").classList.remove("on");
|
||
}
|
||
refreshEditSelection();
|
||
}
|
||
|
||
function updateEditUndoButtons() {
|
||
// Dùng chung 2 nút Hoàn tác/Làm lại toàn cục khi đang ở chế độ sửa.
|
||
if (!state.editMode) return;
|
||
$("undoBtn").disabled = state.editUndo.length === 0;
|
||
$("redoBtn").disabled = state.editRedo.length === 0;
|
||
}
|
||
|
||
// Áp 1 nhóm op (1 thao tác người dùng): lưu editBase cũ vào stack undo,
|
||
// materialize ra file tạm mới, ghi nhận file tạm để dọn khi thoát.
|
||
async function stageEditOps(ops) {
|
||
try {
|
||
const out = await invoke("edit_apply_to_temp", {
|
||
input: state.editBase, page: state.editPage, ops, password: null,
|
||
});
|
||
state.editTemps.push(out);
|
||
state.editUndo.push(state.editBase);
|
||
state.editRedo = [];
|
||
state.editBase = out;
|
||
state.editSel = null;
|
||
selectEditObject(-1);
|
||
await loadEditPage();
|
||
} catch (e) {
|
||
$("editHint").textContent = "Lỗi: " + e;
|
||
}
|
||
}
|
||
|
||
function stageEditOp(op) {
|
||
return stageEditOps([op]);
|
||
}
|
||
|
||
function editUndo() {
|
||
if (!state.editUndo.length) return;
|
||
state.editRedo.push(state.editBase);
|
||
state.editBase = state.editUndo.pop();
|
||
state.editSel = null;
|
||
loadEditPage();
|
||
}
|
||
function editRedo() {
|
||
if (!state.editRedo.length) return;
|
||
state.editUndo.push(state.editBase);
|
||
state.editBase = state.editRedo.pop();
|
||
state.editSel = null;
|
||
loadEditPage();
|
||
}
|
||
|
||
// CSS font-family xấp xỉ cho family PDF (để ô sửa hiển thị đúng dáng chữ).
|
||
// Tên family từ engine là dạng PostScript CamelCase KHÔNG dấu cách
|
||
// ("TimesNewRoman", "SegoeUI", "OpenSans") — không khớp tên font cài trên máy
|
||
// nên CSS rơi về serif/sans-serif mặc định (ô sửa sai font). Map alias phổ biến
|
||
// + tự tách CamelCase thành tên có dấu cách.
|
||
const FONT_CSS_ALIASES = {
|
||
helvetica: "Arial",
|
||
helveticaneue: "Arial",
|
||
arial: "Arial",
|
||
arialmt: "Arial",
|
||
times: "Times New Roman",
|
||
timesroman: "Times New Roman",
|
||
timesnewroman: "Times New Roman",
|
||
timesnewromanps: "Times New Roman",
|
||
courier: "Courier New",
|
||
couriernew: "Courier New",
|
||
couriernewps: "Courier New",
|
||
segoeui: "Segoe UI",
|
||
};
|
||
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 = "<br>";
|
||
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", `<p class="muted">Tài liệu chưa có field form nào. Hãy dùng “➕ Thêm field”.</p>
|
||
<div class="foot"><button id="ffClose" class="primary">Đóng</button></div>`)
|
||
.querySelector("#ffClose").addEventListener("click", closeModal);
|
||
return;
|
||
}
|
||
const rows = fields
|
||
.map((f, i) => {
|
||
const label = `${escapeHtml(f.name)} <span class="muted">(${f.kind}${f.pageIndex != null ? `, trang ${f.pageIndex + 1}` : ""})</span>`;
|
||
let input;
|
||
if (f.kind === "checkbox" || f.kind === "radio") {
|
||
const on = f.value && f.value !== "Off";
|
||
input = `<input type="checkbox" id="ffv${i}" ${on ? "checked" : ""} ${f.readOnly ? "disabled" : ""}>`;
|
||
} else if ((f.kind === "combo" || f.kind === "list") && f.options.length) {
|
||
const opts = f.options
|
||
.map((o) => `<option ${o === f.value ? "selected" : ""}>${escapeHtml(o)}</option>`)
|
||
.join("");
|
||
input = `<select id="ffv${i}" ${f.readOnly ? "disabled" : ""}><option value=""></option>${opts}</select>`;
|
||
} else {
|
||
input = `<input type="text" id="ffv${i}" value="${escapeHtml(f.value || "")}" ${f.readOnly ? "disabled" : ""}>`;
|
||
}
|
||
return `<tr><td>${label}</td><td>${input}</td></tr>`;
|
||
})
|
||
.join("");
|
||
const box = openModal("Điền form", `
|
||
<table class="form-table"><tbody>${rows}</tbody></table>
|
||
<div class="err" id="ffErr"></div>
|
||
<div class="foot"><button id="ffCancel">Huỷ</button><button id="ffOk" class="primary">Lưu & áp dụng…</button></div>
|
||
`);
|
||
box.querySelector("#ffCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#ffOk").addEventListener("click", async () => {
|
||
const values = fields.map((f, i) => {
|
||
const el = box.querySelector(`#ffv${i}`);
|
||
let value;
|
||
if (f.kind === "checkbox" || f.kind === "radio") value = el.checked ? "on" : "off";
|
||
else value = el.value;
|
||
return { name: f.name, value };
|
||
});
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
const n = await invoke("form_fill", { input: state.path, values, output: out });
|
||
closeModal();
|
||
$("status").textContent = `Đã điền ${n} field → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
box.querySelector("#ffErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCreateFieldDialog() {
|
||
const box = openModal("Thêm field mới", `
|
||
<label>Tên field</label>
|
||
<input type="text" id="nfName" placeholder="hoTen">
|
||
<label>Loại</label>
|
||
<select id="nfKind">
|
||
<option value="text">Text (ô nhập chữ)</option>
|
||
<option value="checkbox">Checkbox</option>
|
||
<option value="combo">Combo box (chọn 1)</option>
|
||
</select>
|
||
<div class="row">
|
||
<div><label>Trang</label><input type="number" id="nfPage" value="1" min="1"></div>
|
||
<div><label>Cỡ (rộng×cao pt)</label><input type="text" id="nfSize" value="200x20"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div><label>Vị trí X (pt)</label><input type="number" id="nfX" value="80"></div>
|
||
<div><label>Vị trí Y từ đáy (pt)</label><input type="number" id="nfY" value="700"></div>
|
||
</div>
|
||
<label id="nfOptLabel" class="hidden">Lựa chọn (cách nhau dấu phẩy)</label>
|
||
<input type="text" id="nfOpts" class="hidden" placeholder="Nam, Nữ, Khác">
|
||
<div class="err" id="nfErr"></div>
|
||
<div class="foot"><button id="nfCancel">Huỷ</button><button id="nfOk" class="primary">Tạo & lưu…</button></div>
|
||
`);
|
||
const kindSel = box.querySelector("#nfKind");
|
||
const toggleOpts = () => {
|
||
const isCombo = kindSel.value === "combo";
|
||
box.querySelector("#nfOptLabel").classList.toggle("hidden", !isCombo);
|
||
box.querySelector("#nfOpts").classList.toggle("hidden", !isCombo);
|
||
};
|
||
kindSel.addEventListener("change", toggleOpts);
|
||
box.querySelector("#nfCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#nfOk").addEventListener("click", async () => {
|
||
const name = box.querySelector("#nfName").value.trim();
|
||
const err = box.querySelector("#nfErr");
|
||
if (!name) { err.textContent = "Cần nhập tên field."; return; }
|
||
const kind = kindSel.value;
|
||
const page = Math.max(1, Number(box.querySelector("#nfPage").value) || 1) - 1;
|
||
const m = box.querySelector("#nfSize").value.match(/^\s*(\d+)\s*[x×]\s*(\d+)\s*$/);
|
||
if (!m) { err.textContent = "Cỡ phải dạng rộng×cao, vd 200x20."; return; }
|
||
const w = Number(m[1]), h = Number(m[2]);
|
||
const x = Number(box.querySelector("#nfX").value) || 0;
|
||
const y = Number(box.querySelector("#nfY").value) || 0;
|
||
const options = kind === "combo"
|
||
? box.querySelector("#nfOpts").value.split(",").map((s) => s.trim()).filter(Boolean)
|
||
: [];
|
||
const field = { name, kind, pageIndex: page, rect: [x, y, x + w, y + h], value: "", options };
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("form_create", { input: state.path, fields: [field], output: out });
|
||
closeModal();
|
||
$("status").textContent = `Đã tạo field “${name}” → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
err.textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function flattenFormAction() {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("form_flatten", { input: state.path, output: out, password: null });
|
||
$("status").textContent = `Đã flatten form → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("formHint").textContent = "Lỗi flatten: " + e;
|
||
}
|
||
}
|
||
|
||
async function exportFormData(kind) {
|
||
const out = await invoke("pick_save_data");
|
||
if (!out) return;
|
||
// Đảm bảo đúng đuôi theo lựa chọn.
|
||
let target = out;
|
||
if (kind === "csv" && !/\.csv$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".csv";
|
||
if (kind === "fdf" && !/\.fdf$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".fdf";
|
||
try {
|
||
await invoke("form_export", { input: state.path, output: target });
|
||
$("formHint").textContent = `Đã xuất dữ liệu form → ${shortName(target)}`;
|
||
} catch (e) {
|
||
$("formHint").textContent = "Lỗi xuất: " + e;
|
||
}
|
||
}
|
||
|
||
async function importFormFdf() {
|
||
const fdf = await invoke("pick_fdf");
|
||
if (!fdf) return;
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
const n = await invoke("form_import_fdf", { input: state.path, fdf, output: out });
|
||
$("status").textContent = `Đã nhập ${n} field từ FDF → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("formHint").textContent = "Lỗi nhập FDF: " + e;
|
||
}
|
||
}
|
||
|
||
// ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ----------
|
||
|
||
function toggleSecMode() {
|
||
state.secMode = !state.secMode;
|
||
$("secBar").classList.toggle("hidden", !state.secMode);
|
||
$("secModeBtn").classList.toggle("active", state.secMode);
|
||
if (!state.secMode && state.tool === "redact") setTool(null);
|
||
$("secHint").textContent = "";
|
||
}
|
||
|
||
function updateRedactButtons() {
|
||
const n = state.redactMarks.length;
|
||
$("redactCount").textContent = n;
|
||
$("secRedactApply").disabled = n === 0;
|
||
$("secRedactClear").disabled = n === 0;
|
||
}
|
||
|
||
function clearRedactMarks() {
|
||
const pages = [...new Set(state.redactMarks.map((m) => m.page))];
|
||
state.redactMarks = [];
|
||
pages.forEach((p) => drawAnnotsForPage(p));
|
||
updateRedactButtons();
|
||
}
|
||
|
||
// Áp dụng redact: XOÁ THẬT nội dung trong các vùng đã đánh dấu → file mới.
|
||
async function applyRedactions() {
|
||
if (!state.redactMarks.length) return;
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
// Gom theo trang: [{page, rects: [[l,b,r,t], ...]}]
|
||
const byPage = new Map();
|
||
for (const m of state.redactMarks) {
|
||
if (!byPage.has(m.page)) byPage.set(m.page, []);
|
||
byPage.get(m.page).push([m.rect.left, m.rect.bottom, m.rect.right, m.rect.top]);
|
||
}
|
||
const areas = [...byPage.entries()].map(([page, rects]) => ({ page, rects }));
|
||
try {
|
||
const n = await invoke("redact_apply", { input: state.path, areas, output: out, password: null });
|
||
clearRedactMarks();
|
||
setTool(null);
|
||
$("status").textContent = `Đã redact (xoá thật) ${n} đối tượng → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("secHint").textContent = "Lỗi redact: " + e;
|
||
}
|
||
}
|
||
|
||
function openEncryptDialog() {
|
||
const box = openModal("Đặt mật khẩu (AES-256)", `
|
||
<label>Mật khẩu mở file (user password)</label>
|
||
<input type="password" id="secPw1" autocomplete="new-password">
|
||
<label>Nhập lại mật khẩu</label>
|
||
<input type="password" id="secPw2" autocomplete="new-password">
|
||
<label>Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)</label>
|
||
<input type="password" id="secPwOwner" autocomplete="new-password">
|
||
<label>Quyền hạn khi mở bằng mật khẩu user</label>
|
||
<div class="row">
|
||
<label><input type="checkbox" id="secPermPrint" checked> In</label>
|
||
<label><input type="checkbox" id="secPermModify" checked> Sửa nội dung</label>
|
||
</div>
|
||
<div class="row">
|
||
<label><input type="checkbox" id="secPermExtract" checked> Sao chép text/ảnh</label>
|
||
<label><input type="checkbox" id="secPermAnnotate" checked> Chú thích/điền form</label>
|
||
</div>
|
||
<div class="err" id="secEncErr"></div>
|
||
<div class="foot"><button id="secEncCancel">Huỷ</button><button id="secEncOk" class="primary">Mã hoá…</button></div>
|
||
`);
|
||
box.querySelector("#secEncCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#secEncOk").addEventListener("click", async () => {
|
||
const p1 = box.querySelector("#secPw1").value;
|
||
const p2 = box.querySelector("#secPw2").value;
|
||
const err = box.querySelector("#secEncErr");
|
||
if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; }
|
||
if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; }
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("security_encrypt", {
|
||
input: state.path,
|
||
output: out,
|
||
userPassword: p1,
|
||
ownerPassword: box.querySelector("#secPwOwner").value,
|
||
allowPrint: box.querySelector("#secPermPrint").checked,
|
||
allowModify: box.querySelector("#secPermModify").checked,
|
||
allowExtract: box.querySelector("#secPermExtract").checked,
|
||
allowAnnotate: box.querySelector("#secPermAnnotate").checked,
|
||
});
|
||
closeModal();
|
||
$("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`;
|
||
$("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên.";
|
||
} catch (e) {
|
||
err.textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
function openDecryptDialog() {
|
||
const box = openModal("Gỡ mật khẩu", `
|
||
<p class="muted">Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.</p>
|
||
<label>Mật khẩu hiện tại</label>
|
||
<input type="password" id="secDecPw" autocomplete="current-password">
|
||
<div class="err" id="secDecErr"></div>
|
||
<div class="foot"><button id="secDecCancel">Huỷ</button><button id="secDecOk" class="primary">Chọn file & gỡ…</button></div>
|
||
`);
|
||
box.querySelector("#secDecCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#secDecOk").addEventListener("click", async () => {
|
||
const pw = box.querySelector("#secDecPw").value;
|
||
const err = box.querySelector("#secDecErr");
|
||
if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; }
|
||
const inp = await invoke("pick_pdf");
|
||
if (!inp) return;
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("security_decrypt", { input: inp, password: pw, output: out });
|
||
closeModal();
|
||
$("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
err.textContent = "Lỗi (mật khẩu sai?): " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function stripMetadataAction() {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("security_strip_metadata", { input: state.path, output: out });
|
||
$("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("secHint").textContent = "Lỗi xoá metadata: " + e;
|
||
}
|
||
}
|
||
|
||
async function optimizeSaveAction() {
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("security_optimize", { input: state.path, output: out });
|
||
$("status").textContent = `Đã lưu tối ưu (nén + dọn rác) → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
} catch (e) {
|
||
$("secHint").textContent = "Lỗi lưu tối ưu: " + e;
|
||
}
|
||
}
|
||
|
||
// --- Chữ ký số (Phase 5 iteration 2) ---
|
||
|
||
function openCreateIdDialog() {
|
||
const box = openModal("Tạo Digital ID tự ký", `
|
||
<p class="muted">Tạo chứng chỉ tự ký (RSA-2048) để ký thử. Nếu có chữ ký của tổ chức phát hành (PFX/PEM) thì dùng thẳng nó ở bước Ký số.</p>
|
||
<label>Tên hiển thị trong chữ ký</label>
|
||
<input type="text" id="idCn" placeholder="Nguyễn Văn A">
|
||
<div class="err" id="idErr"></div>
|
||
<div class="foot"><button id="idCancel">Huỷ</button><button id="idOk" class="primary">Tạo & lưu…</button></div>
|
||
`);
|
||
box.querySelector("#idCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#idOk").addEventListener("click", async () => {
|
||
const cn = box.querySelector("#idCn").value.trim();
|
||
if (!cn) { box.querySelector("#idErr").textContent = "Cần nhập tên."; return; }
|
||
const out = await invoke("pick_save_pem");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("sig_create_id", { commonName: cn, output: out });
|
||
closeModal();
|
||
$("secHint").textContent = `Đã tạo Digital ID → ${shortName(out)} (dùng ở bước Ký số)`;
|
||
} catch (e) {
|
||
box.querySelector("#idErr").textContent = "Lỗi: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
function openSignDialog() {
|
||
const box = openModal("Ký số tài liệu", `
|
||
<label>Tên người ký (hiện trong chữ ký)</label>
|
||
<input type="text" id="sgName" placeholder="Nguyễn Văn A">
|
||
<label>Lý do ký</label>
|
||
<input type="text" id="sgReason" value="Tôi đồng ý với nội dung tài liệu">
|
||
<label>Digital ID (.pem)</label>
|
||
<div class="row">
|
||
<input type="text" id="sgId" readonly placeholder="Chưa chọn — bấm Chọn…">
|
||
<button id="sgPickId">Chọn…</button>
|
||
</div>
|
||
<div class="err" id="sgErr"></div>
|
||
<div class="foot"><button id="sgCancel">Huỷ</button><button id="sgOk" class="primary">Ký & lưu…</button></div>
|
||
`);
|
||
let idPath = null;
|
||
box.querySelector("#sgPickId").addEventListener("click", async () => {
|
||
const p = await invoke("pick_any_file");
|
||
if (p) { idPath = p; box.querySelector("#sgId").value = shortName(p); }
|
||
});
|
||
box.querySelector("#sgCancel").addEventListener("click", closeModal);
|
||
box.querySelector("#sgOk").addEventListener("click", async () => {
|
||
const name = box.querySelector("#sgName").value.trim();
|
||
const reason = box.querySelector("#sgReason").value.trim();
|
||
const err = box.querySelector("#sgErr");
|
||
if (!name) { err.textContent = "Cần nhập tên người ký."; return; }
|
||
if (!idPath) { err.textContent = "Cần chọn Digital ID (.pem)."; return; }
|
||
const out = await invoke("pick_save_pdf");
|
||
if (!out) return;
|
||
try {
|
||
await invoke("sig_sign", { input: state.path, idPem: idPath, reason, signerName: name, output: out });
|
||
closeModal();
|
||
$("status").textContent = `Đã ký số → ${shortName(out)}`;
|
||
loadDocument(out);
|
||
// Tự kiểm tra chữ ký vừa tạo.
|
||
verifySignaturesAction(out);
|
||
} catch (e) {
|
||
err.textContent = "Lỗi ký: " + e;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function verifySignaturesAction(pathOverride) {
|
||
const target = typeof pathOverride === "string" ? pathOverride : state.path;
|
||
let checks;
|
||
try {
|
||
checks = await invoke("sig_verify", { input: target });
|
||
} catch (e) {
|
||
$("secHint").textContent = "Lỗi kiểm tra: " + e;
|
||
return;
|
||
}
|
||
const rows = checks.length
|
||
? checks
|
||
.map((c, i) => {
|
||
const badge = c.valid
|
||
? '<span class="sig-ok">✓ Hợp lệ</span>'
|
||
: '<span class="sig-bad">✗ Không hợp lệ</span>';
|
||
const detail = [];
|
||
if (!c.cryptoValid) detail.push("chữ ký sai");
|
||
if (!c.digestMatches) detail.push("nội dung đã bị sửa");
|
||
if (!c.coversDocument) detail.push("có phần thêm sau khi ký");
|
||
return `<tr><td>${i + 1}</td><td>${(c.signer || "").replace(/</g, "<")}</td><td>${badge}</td>
|
||
<td class="muted">${detail.join("; ")}</td></tr>`;
|
||
})
|
||
.join("")
|
||
: `<tr><td colspan="4" class="muted">Tài liệu chưa có chữ ký số nào.</td></tr>`;
|
||
const box = openModal("Kết quả kiểm tra chữ ký", `
|
||
<table class="sig-table">
|
||
<thead><tr><th>#</th><th>Người ký</th><th>Trạng thái</th><th>Ghi chú</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
<div class="foot"><button id="sigClose" class="primary">Đóng</button></div>
|
||
`);
|
||
box.querySelector("#sigClose").addEventListener("click", closeModal);
|
||
}
|
||
|
||
// ---------- Sự kiện ----------
|
||
|
||
$("openBtn").addEventListener("click", openFile);
|
||
|
||
// 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);
|