Vong 6: edit mode NHIEU TRANG + nut Huy thay doi + fix de chu khi luu dong lan style
Build Windows portable / build (push) Canceled after 0s

- Engine (edit.rs): sau khi ve tung manh rich, tien con tro theo MAX(do hmtx,
  MEP MUC THAT tu PDFium bounds) - subset font ttf-parser khong parse duoc roi
  ve fallback 0.5em/ky tu uoc luong thieu ("HttpOnly" do 36pt vs muc that
  42.6pt) lam manh sau de len manh truoc ("auth-storagetu"). Ca duong ve ca
  manh lan duong be theo tung tu.
- UI nhieu trang: #editPagesCol chua 1 .edit-slot/trang; trang active giu
  #editPageWrap (img+overlay cu, chi DI CHUYEN giua cac slot), trang khac
  render read-only luoi (IntersectionObserver, stamp editBase|zoom tu
  invalidate sau op/undo/zoom). Dup vao trang read-only = chuyen active + mo
  o sua ngay tai diem dup (tai dung editPendingPoint). Cuon: so trang bam
  theo trang giua khung nhin, tu doi active khi khong co o sua mo. Zoom co
  gian moi slot ngay, render net sau.
- Nut "Huy thay doi" canh nut Luu: dong o sua, xoa file tam, editBase ve file
  goc, xoa undo/redo, moi trang tu render lai noi dung goc.
- E2E CDP tren app that (tiem patch vao build 29): cot 3 trang, dup trang 3
  read-only mo dung dong, Huy tra ve goc (text thu "KIEM" bien mat), zoom
  130% ca 3 slot. Docs/12 vong 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 09:52:01 +07:00
co-authored by Claude Fable 5
parent 1362efef63
commit 21314281a8
5 changed files with 289 additions and 11 deletions
+8 -3
View File
@@ -102,6 +102,7 @@
<button id="edColorBtn" class="atool" title="Màu chữ" disabled><span class="sw" id="edSw"></span> Màu</button>
<span class="spacer"></span>
<span id="editHint" class="status"></span>
<button id="edDiscard" disabled title="Bỏ mọi thay đổi chưa lưu, quay về nội dung gốc">↩ Huỷ thay đổi</button>
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
</div>
@@ -172,9 +173,13 @@
<main id="organizeGrid" class="organize-grid hidden"></main>
<main id="editStage" class="edit-stage hidden">
<div id="editPageWrap" class="edit-page-wrap">
<img id="editImg" class="edit-img" alt="trang đang sửa"/>
<div id="editOverlay" class="edit-overlay"></div>
<!-- Cột MỌI trang: trang active chứa #editPageWrap (img+overlay sửa
được), các trang khác render read-only — đúp vào là sửa trang đó. -->
<div id="editPagesCol" class="edit-pages-col">
<div id="editPageWrap" class="edit-page-wrap">
<img id="editImg" class="edit-img" alt="trang đang sửa"/>
<div id="editOverlay" class="edit-overlay"></div>
</div>
</div>
</main>
</div>
+187 -4
View File
@@ -420,11 +420,20 @@ function goToPage(idx) {
if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Chuyển trang trong chế độ Sửa nội dung (undo/redo dùng chung editBase nên
// vẫn giữ nguyên các thay đổi đã áp ở trang khác).
function switchEditPage(idx) {
// Chuyển trang ACTIVE trong chế độ Sửa nội dung (undo/redo dùng chung
// editBase nên vẫn giữ nguyên các thay đổi đã áp ở trang khác).
// `scrollBlock`: "start" (nav nút/outline), "nearest" (đúp tại chỗ — không
// giật màn hình), null (người dùng đang tự cuộn — không cuộn hộ).
function switchEditPage(idx, scrollBlock) {
const i = Math.max(0, Math.min(state.pages.length - 1, idx));
if (i === state.editPage) return;
if (i === state.editPage) {
// Vẫn cuộn về trang active (gõ lại số trang sau khi đã cuộn đi xa).
const slot = (state.editSlots || [])[i];
if (slot && scrollBlock !== null) {
slot.scrollIntoView({ block: scrollBlock || "start", behavior: "smooth" });
}
return;
}
const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove(); // đóng ô sửa đang mở, không lưu
state.editPage = i;
@@ -432,6 +441,113 @@ function switchEditPage(idx) {
selectEditObject(-1);
$("pageInput").value = i + 1;
loadEditPage();
const slot = (state.editSlots || [])[i];
if (slot && scrollBlock !== null) {
slot.scrollIntoView({ block: scrollBlock || "start", behavior: "smooth" });
}
}
// ---------- Chế độ sửa NHIỀU TRANG ----------
// Cột đủ mọi trang như viewer: trang active = #editPageWrap (img + overlay
// sửa được), trang khác render ảnh read-only LƯỜI khi cuộn tới. Đúp vào
// trang read-only → trang đó thành active + mở ô sửa ngay tại điểm đúp.
let editWrapEl = null; // #editPageWrap — giữ tham chiếu sống khi dựng lại cột
let editRoObserver = null;
const editVisibleRo = new Set(); // index các slot read-only đang trong khung nhìn
// Ảnh read-only hợp lệ khi CẢ editBase lẫn zoom chưa đổi từ lúc render.
function editRoStamp() {
return state.editBase + "|" + state.editZoom.toFixed(3);
}
function sizeEditSlot(slot, p) {
const w = editStageWidth(p);
slot.style.width = w + "px";
slot.style.height = Math.round((w * p.heightPt) / p.widthPt) + "px";
}
function buildEditColumn() {
const col = $("editPagesCol");
editWrapEl = editWrapEl || $("editPageWrap");
if (editWrapEl.parentElement) editWrapEl.remove(); // tách ra trước khi xoá cột
col.innerHTML = "";
editVisibleRo.clear();
if (editRoObserver) editRoObserver.disconnect();
state.editSlots = [];
for (const p of state.pages) {
const slot = document.createElement("div");
slot.className = "edit-slot";
slot.dataset.page = String(p.index);
sizeEditSlot(slot, p);
col.appendChild(slot);
state.editSlots.push(slot);
}
placeEditWrap();
editRoObserver = new IntersectionObserver(
(entries) => {
for (const en of entries) {
const idx = Number(en.target.dataset.page);
if (en.isIntersecting) {
editVisibleRo.add(idx);
fillRoSlot(idx);
} else {
editVisibleRo.delete(idx);
}
}
},
{ root: $("editStage"), rootMargin: "300px" }
);
for (const slot of state.editSlots) editRoObserver.observe(slot);
}
// Đặt wrap sửa được vào slot của trang active; slot cũ quay về ảnh read-only.
function placeEditWrap() {
const slot = (state.editSlots || [])[state.editPage];
if (!slot || slot.contains(editWrapEl)) return;
const old = editWrapEl.parentElement;
const ro = slot.querySelector("img.edit-ro");
if (ro) ro.remove();
slot.dataset.rendered = ""; // slot này giờ hiển thị bằng wrap
slot.appendChild(editWrapEl);
if (old && old.classList && old.classList.contains("edit-slot")) {
old.dataset.rendered = "";
fillRoSlot(Number(old.dataset.page));
}
}
// Render ảnh read-only cho 1 trang không-active nếu đang thấy và ảnh cũ đã
// stale; stamp chống render trùng khi observer + refresh gọi chồng nhau.
async function fillRoSlot(idx) {
if (idx === state.editPage) return;
const slot = (state.editSlots || [])[idx];
if (!slot || !editVisibleRo.has(idx)) return;
const stamp = editRoStamp();
if (slot.dataset.rendered === stamp) return;
slot.dataset.rendered = stamp;
try {
const url = await invoke("render_page", {
path: state.editBase, page: idx, width: editStageWidth(state.pages[idx]),
});
// Trong lúc render: trang này thành active / có lượt mới hơn → bỏ kết quả.
if (idx === state.editPage || slot.dataset.rendered !== stamp) return;
let img = slot.querySelector("img.edit-ro");
if (!img) {
img = document.createElement("img");
img.className = "edit-ro";
img.alt = "trang " + (idx + 1);
slot.appendChild(img);
}
img.src = url;
} catch (_) {
slot.dataset.rendered = ""; // để lần cuộn/refresh sau thử lại
}
}
// Sau mỗi op/undo/redo/zoom: các trang read-only đang thấy render lại nếu cần
// (stamp đổi theo editBase + zoom nên gọi thoải mái, không render thừa).
function refreshRoSlots() {
for (const idx of editVisibleRo) fillRoSlot(idx);
}
// ---------- Zoom ----------
@@ -450,6 +566,10 @@ function setEditZoom(z) {
const w = editStageWidth(p);
state.editScale = w / p.widthPt;
$("editImg").style.width = w + "px";
// Mọi slot trong cột co giãn theo ngay (ảnh cũ scale CSS, render nét sau).
if (state.editSlots) {
for (const pg of state.pages) sizeEditSlot(state.editSlots[pg.index], pg);
}
buildEditOverlay();
updateZoomLabel();
scheduleEditSharpRender();
@@ -468,6 +588,7 @@ function scheduleEditSharpRender() {
if (seq !== editLoadSeq) return; // trang/file đã đổi trong lúc render
$("editImg").src = url;
} catch (_) { /* giữ ảnh cũ (đã co giãn CSS) */ }
refreshRoSlots(); // các trang read-only đang thấy cũng render nét ở zoom mới
}, 220);
}
@@ -2131,6 +2252,10 @@ function enterEditMode(pageIndex) {
$("sidebar").classList.add("hidden");
$("editModeBtn").classList.add("active");
setTool(null);
buildEditColumn();
// Đứng ngay tại trang vào sửa (không smooth — vừa mở chế độ, chưa có ngữ cảnh cuộn).
const slot0 = state.editSlots[state.editPage];
if (slot0) slot0.scrollIntoView({ block: "start", behavior: "instant" });
loadEditPage();
}
// Dọn mọi file làm việc tạm của phiên sửa (backend chỉ xoá đúng ff_edit_*.pdf
@@ -2201,6 +2326,7 @@ async function loadEditPage() {
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]);
if (mySeq !== editLoadSeq) return; // đã có lượt nạp mới hơn — bỏ lượt này
placeEditWrap(); // wrap sửa được nằm đúng slot của trang active
// Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text
// được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
// cần mở gói trang.
@@ -2216,7 +2342,9 @@ async function loadEditPage() {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e;
}
$("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào
$("edDiscard").disabled = state.editBase === state.path;
updateEditUndoButtons();
refreshRoSlots(); // editBase có thể vừa đổi (op/undo/redo) → trang khác render lại
// Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp.
const pending = state.editPendingPoint;
@@ -3273,6 +3401,21 @@ async function replaceSelectedImage() {
stageEditOp({ op: "replaceImage", index: o.index, imagePath: path });
}
// Huỷ MỌI thay đổi chưa lưu: quay về file gốc, xoá sạch undo/redo + file tạm.
function discardEdits() {
if (state.editBase === state.path) return; // chưa có gì để huỷ
const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove();
cleanupEditTemps(); // editBase cũ nằm trong editTemps — xoá luôn
state.editBase = state.path;
state.editUndo = [];
state.editRedo = [];
state.editSel = null;
selectEditObject(-1);
loadEditPage();
$("status").textContent = "Đã huỷ mọi thay đổi nội dung — trở về file gốc.";
}
async function saveEdits() {
const out = await invoke("pick_save_pdf");
if (!out) return;
@@ -4025,6 +4168,7 @@ $("edColorBtn").addEventListener("click", () => {
});
});
$("edSave").addEventListener("click", saveEdits);
$("edDiscard").addEventListener("click", discardEdits);
$("editOverlay").addEventListener("click", onEditStageClick);
// Đúp vào KHE giữa các khung (PDF cắt run rời rạc) vẫn mở ô sửa tại chữ gần đó.
$("editOverlay").addEventListener("dblclick", (e) => {
@@ -4038,6 +4182,45 @@ $("editOverlay").addEventListener("dblclick", (e) => {
const hit = pickTextAt(state.editObjects, x, y);
if (hit) startTextEdit(hit, e);
});
// Đúp vào trang READ-ONLY trong cột: trang đó thành active + mở ô sửa ngay
// tại điểm đúp (qua editPendingPoint — cùng cơ chế với đúp từ viewer thường).
$("editPagesCol").addEventListener("dblclick", (e) => {
if (state.editArm) return;
const slot = e.target.closest && e.target.closest(".edit-slot");
if (!slot || slot.contains(editWrapEl)) return; // trang active: overlay lo
const idx = Number(slot.dataset.page);
const p = state.pages[idx];
const r = slot.getBoundingClientRect();
const sc = r.width / p.widthPt;
state.editPendingPoint = {
x: (e.clientX - r.left) / sc,
y: p.heightPt - (e.clientY - r.top) / sc,
};
switchEditPage(idx, "nearest");
});
// Cuộn trong chế độ sửa: ô chỉ số trang bám theo trang đang nhìn; khi không
// có ô sửa mở, trang giữa khung nhìn tự thành trang active (nav ▲▼, toolbar
// và phím tắt luôn tác động đúng trang người dùng đang xem).
let editScrollTimer = null;
$("editStage").addEventListener("scroll", () => {
if (!state.editMode || !(state.editSlots || []).length) return;
clearTimeout(editScrollTimer);
editScrollTimer = setTimeout(() => {
const st = $("editStage").getBoundingClientRect();
const mid = st.top + ($("editStage").clientHeight * 0.45);
let best = state.editPage;
let bestD = Infinity;
state.editSlots.forEach((slot, i) => {
const r = slot.getBoundingClientRect();
const d = Math.abs((r.top + r.bottom) / 2 - mid);
if (d < bestD) { bestD = d; best = i; }
});
$("pageInput").value = best + 1;
if (best !== state.editPage && !$("editOverlay").querySelector(".edit-inline")) {
switchEditPage(best, null); // người dùng đang tự cuộn — không cuộn hộ
}
}, 200);
});
// Phím tắt chuẩn của trình xem PDF (Foxit/Adobe): điều hướng trang, zoom,
// tìm kiếm, xoá/bỏ chọn annotation.
+11
View File
@@ -364,6 +364,8 @@ body.tool-active .annotlayer * { pointer-events: none; }
.editbar button.armed { background: var(--accent); }
#edSave { background: #2e7d32; }
#edSave:disabled { background: #555; }
#edDiscard { background: #8d3030; }
#edDiscard:disabled { background: #555; }
.edit-stage {
flex: 1;
@@ -388,6 +390,15 @@ body.tool-active .annotlayer * { pointer-events: none; }
/* margin auto (thay vì chỉ justify-content) để khi zoom to hơn khung, mép
trái không bị cắt mất — bug kinh điển của flex center + overflow. */
.edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; margin: auto; }
/* Cột MỌI trang trong chế độ sửa: trang active chứa .edit-page-wrap, các
trang khác là ảnh read-only render lười — đúp vào là sửa trang đó. */
.edit-pages-col { display: flex; flex-direction: column; gap: 18px; margin: auto; }
.edit-slot { position: relative; flex: none; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,.5); }
.edit-slot > img.edit-ro { display: block; width: 100%; height: 100%; }
/* Slot chứa wrap active: bỏ trang trí trùng lặp, cao theo ảnh thật (render
có thể lệch 1px so với chiều cao ước tính của slot). */
.edit-slot .edit-page-wrap { margin: 0; box-shadow: none; }
.edit-slot:has(.edit-page-wrap) { height: auto !important; }
.edit-img { display: block; }
.edit-overlay { position: absolute; inset: 0; }
.edit-overlay.armed { cursor: crosshair; }