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
+5
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> <button id="edColorBtn" class="atool" title="Màu chữ" disabled><span class="sw" id="edSw"></span> Màu</button>
<span class="spacer"></span> <span class="spacer"></span>
<span id="editHint" class="status"></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> <button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
</div> </div>
@@ -172,10 +173,14 @@
<main id="organizeGrid" class="organize-grid hidden"></main> <main id="organizeGrid" class="organize-grid hidden"></main>
<main id="editStage" class="edit-stage hidden"> <main id="editStage" class="edit-stage hidden">
<!-- 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"> <div id="editPageWrap" class="edit-page-wrap">
<img id="editImg" class="edit-img" alt="trang đang sửa"/> <img id="editImg" class="edit-img" alt="trang đang sửa"/>
<div id="editOverlay" class="edit-overlay"></div> <div id="editOverlay" class="edit-overlay"></div>
</div> </div>
</div>
</main> </main>
</div> </div>
+187 -4
View File
@@ -420,11 +420,20 @@ function goToPage(idx) {
if (slot) slot.scrollIntoView({ behavior: "smooth", block: "start" }); 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 // Chuyển trang ACTIVE trong chế độ Sửa nội dung (undo/redo dùng chung
// vẫn giữ nguyên các thay đổi đã áp ở trang khác). // editBase nên vẫn giữ nguyên các thay đổi đã áp ở trang khác).
function switchEditPage(idx) { // `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)); 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"); const ce = $("editOverlay").querySelector(".edit-inline");
if (ce) ce.remove(); // đóng ô sửa đang mở, không lưu if (ce) ce.remove(); // đóng ô sửa đang mở, không lưu
state.editPage = i; state.editPage = i;
@@ -432,6 +441,113 @@ function switchEditPage(idx) {
selectEditObject(-1); selectEditObject(-1);
$("pageInput").value = i + 1; $("pageInput").value = i + 1;
loadEditPage(); 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 ---------- // ---------- Zoom ----------
@@ -450,6 +566,10 @@ function setEditZoom(z) {
const w = editStageWidth(p); const w = editStageWidth(p);
state.editScale = w / p.widthPt; state.editScale = w / p.widthPt;
$("editImg").style.width = w + "px"; $("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(); buildEditOverlay();
updateZoomLabel(); updateZoomLabel();
scheduleEditSharpRender(); scheduleEditSharpRender();
@@ -468,6 +588,7 @@ function scheduleEditSharpRender() {
if (seq !== editLoadSeq) return; // trang/file đã đổi trong lúc render if (seq !== editLoadSeq) return; // trang/file đã đổi trong lúc render
$("editImg").src = url; $("editImg").src = url;
} catch (_) { /* giữ ảnh cũ (đã co giãn CSS) */ } } 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); }, 220);
} }
@@ -2131,6 +2252,10 @@ function enterEditMode(pageIndex) {
$("sidebar").classList.add("hidden"); $("sidebar").classList.add("hidden");
$("editModeBtn").classList.add("active"); $("editModeBtn").classList.add("active");
setTool(null); 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(); 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 // 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 }), 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 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 // 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 xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
// cần mở gói trang. // cần mở gói trang.
@@ -2216,7 +2342,9 @@ async function loadEditPage() {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e; $("editHint").textContent = "Lỗi nạp trang sửa: " + e;
} }
$("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào $("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào
$("edDiscard").disabled = state.editBase === state.path;
updateEditUndoButtons(); 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. // Đú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; const pending = state.editPendingPoint;
@@ -3273,6 +3401,21 @@ async function replaceSelectedImage() {
stageEditOp({ op: "replaceImage", index: o.index, imagePath: path }); 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() { async function saveEdits() {
const out = await invoke("pick_save_pdf"); const out = await invoke("pick_save_pdf");
if (!out) return; if (!out) return;
@@ -4025,6 +4168,7 @@ $("edColorBtn").addEventListener("click", () => {
}); });
}); });
$("edSave").addEventListener("click", saveEdits); $("edSave").addEventListener("click", saveEdits);
$("edDiscard").addEventListener("click", discardEdits);
$("editOverlay").addEventListener("click", onEditStageClick); $("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 đó. // Đú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) => { $("editOverlay").addEventListener("dblclick", (e) => {
@@ -4038,6 +4182,45 @@ $("editOverlay").addEventListener("dblclick", (e) => {
const hit = pickTextAt(state.editObjects, x, y); const hit = pickTextAt(state.editObjects, x, y);
if (hit) startTextEdit(hit, e); 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, // 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. // 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); } .editbar button.armed { background: var(--accent); }
#edSave { background: #2e7d32; } #edSave { background: #2e7d32; }
#edSave:disabled { background: #555; } #edSave:disabled { background: #555; }
#edDiscard { background: #8d3030; }
#edDiscard:disabled { background: #555; }
.edit-stage { .edit-stage {
flex: 1; 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 /* 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. */ 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; } .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-img { display: block; }
.edit-overlay { position: absolute; inset: 0; } .edit-overlay { position: absolute; inset: 0; }
.edit-overlay.armed { cursor: crosshair; } .edit-overlay.armed { cursor: crosshair; }
+16 -4
View File
@@ -1748,7 +1748,10 @@ pub fn apply_edits(
}; };
// Ghi + tự kiểm; hỏng → ghi lại TỪNG TỪ tự đặt // Ghi + tự kiểm; hỏng → ghi lại TỪNG TỪ tự đặt
// vị trí (không bao giờ ghi glyph dấu cách). // vị trí (không bao giờ ghi glyph dấu cách).
let wrote_ok = { // Mảnh kế đặt từ MÉP MỰC THẬT (bounds PDFium):
// hmtx subset có thể không parse được → đo xấp
// xỉ 0.5em thiếu hụt làm các mảnh ĐÈ nhau.
let (wrote_ok, ink_right) = {
let mut obj = page let mut obj = page
.objects_mut() .objects_mut()
.create_text_object( .create_text_object(
@@ -1763,12 +1766,14 @@ pub fn apply_edits(
.as_text_object() .as_text_object()
.map(|t| t.text() == p.text) .map(|t| t.text() == p.text)
.unwrap_or(false); .unwrap_or(false);
let mut ir = None;
if ok { if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y)) obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?; .map_err(err)?;
obj.set_fill_color(st.color).map_err(err)?; obj.set_fill_color(st.color).map_err(err)?;
ir = obj.bounds().ok().map(|q| q.right().value);
} }
ok (ok, ir)
}; };
if !wrote_ok { if !wrote_ok {
let idx = page.objects().len().saturating_sub(1); let idx = page.objects().len().saturating_sub(1);
@@ -1793,10 +1798,17 @@ pub fn apply_edits(
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y)) obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
.map_err(err)?; .map_err(err)?;
obj.set_fill_color(st.color).map_err(err)?; obj.set_fill_color(st.color).map_err(err)?;
cx += measure_s(p.style, word) + space_w; let wir =
obj.bounds().ok().map(|q| q.right().value);
cx = (cx + measure_s(p.style, word))
.max(wir.unwrap_or(0.0))
+ space_w;
} }
x = cx - space_w;
} else {
x = (x + measure_s(p.style, &p.text))
.max(ink_right.unwrap_or(0.0));
} }
x += measure_s(p.style, &p.text);
} }
} }
} }
+67
View File
@@ -455,3 +455,70 @@ Bật chế độ Sửa nội dung (overlay 2 đối tượng); double-click dò
- Đặt ảnh theo đúng tỉ lệ gốc (hiện mặc định 150×112pt, resize bằng kéo handle); preview ảnh trước khi đặt. - Đặt ảnh theo đúng tỉ lệ gốc (hiện mặc định 150×112pt, resize bằng kéo handle); preview ảnh trước khi đặt.
- Sửa nhiều trang trong 1 phiên lưu (hiện lưu theo trang đang sửa); spell-check; sửa bảng; link text blocks. - Sửa nhiều trang trong 1 phiên lưu (hiện lưu theo trang đang sửa); spell-check; sửa bảng; link text blocks.
- Mã hoá UI (nợ từ Phase 3). - Mã hoá UI (nợ từ Phase 3).
---
# Vòng 6 (10/08/2026): sửa NHIỀU TRANG + nút Huỷ + fix đè chữ khi lưu dòng lẫn style
## 1. Fix đè chữ khi lưu dòng lẫn style (engine, `edit.rs`)
**Triệu chứng:** sửa dòng có nhiều style (thường + mono-bold, ví dụ "Các thuộc
tính `HttpOnly`, `Secure` và `SameSite`...") → sau commit các mảnh chữ đè lên
nhau ("auth-storagetừ", dấu "," nằm đè trong chữ trước).
**Nguyên nhân:** con trỏ x đặt mảnh KẾ TIẾP tiến theo bề rộng ĐO bằng hmtx
(ttf-parser). Với subset font mà ttf-parser không parse được (`measure=None`),
fallback 0.5em/ký tự ƯỚC LƯỢNG THIẾU (đo "HttpOnly" = 36pt trong khi mực thật
42.6pt) → mảnh sau bắt đầu bên trong mực mảnh trước.
**Fix:** sau khi vẽ từng mảnh rich, đọc `obj.bounds()` từ PDFium (mép mực
thật) và tiến con trỏ bằng `max(x + measured, ink_right)` — cả đường vẽ cả
mảnh lẫn đường fallback bẻ theo từng từ. Ước lượng thiếu bao nhiêu cũng không
đè được nữa; ước lượng thừa giữ nguyên hành vi cũ.
## 2. Chế độ sửa NHIỀU TRANG (UI)
**Yêu cầu user:** "khi edit text ở trang nào thì các trang còn lại biến mất,
chỉ xem được trang đó" → giờ chế độ sửa hiển thị CỘT MỌI TRANG như viewer.
Kiến trúc (`#editPagesCol` trong `#editStage`):
- Mỗi trang 1 `.edit-slot` kích thước theo zoom. Trang ACTIVE chứa
`#editPageWrap` (img + overlay sửa được — giữ nguyên toàn bộ code 1-trang
cũ, chỉ DI CHUYỂN wrap giữa các slot). Trang khác render ảnh read-only
LƯỜI bằng IntersectionObserver (rootMargin 300px).
- Stamp `editBase + "|" + zoom` trên mỗi slot: op/undo/redo đổi editBase,
zoom đổi mức — stamp lệch là render lại khi trang còn trong khung nhìn.
`refreshRoSlots()` gọi ở cuối `loadEditPage` + sau render nét debounce.
- **Đúp vào trang read-only** → trang đó thành active + mở ô sửa NGAY tại
điểm đúp (tái dùng cơ chế `editPendingPoint` của luồng đúp-từ-viewer).
- **Cuộn** (debounce 200ms): ô số trang bám trang giữa khung nhìn; nếu không
có ô sửa đang mở, trang đó tự thành active (toolbar/phím tắt luôn tác động
đúng trang đang xem). Không auto-switch khi ô sửa đang mở — không mất chữ.
- Zoom: mọi slot co giãn CSS ngay, render nét sau 220ms (trang active) +
refresh trang read-only đang thấy. Nav ▲▼/ô số trang → `switchEditPage`
cuộn `scrollIntoView` (start khi nav, nearest khi đúp — không giật).
## 3. Nút "↩ Huỷ thay đổi" (UI)
Cạnh nút Lưu; bật khi `editBase !== path` (đồng bộ cùng chỗ với edSave trong
`loadEditPage`). `discardEdits()`: đóng ô sửa đang mở → xoá file tạm
(`cleanupEditTemps` — editBase cũ nằm trong `editTemps`) → `editBase = path`,
xoá sạch undo/redo → `loadEditPage()` (stamp đổi nên mọi trang read-only tự
render lại về nội dung gốc).
## 4. Kiểm chứng E2E (CDP trên app thật, file dịch 3 trang)
Tiêm bản vá vào build 29 đang chạy (không cần rebuild) rồi đo:
- Cột 3 slot, wrap nằm đúng slot active, trang 1+2 read-only render khi cuộn.
- Cuộn tới trang 2 → `pageInput=2`, active tự đổi; slot cũ trả về read-only.
- Đúp lên trang 3 (read-only) → active=2, ô sửa mở đúng dòng "ĐÁNH GIÁ AN
TOÀN THÔNG TIN" tại điểm đúp.
- Huỷ thay đổi: `editBase === path`, edSave/edDiscard disabled, undo/redo
rỗng, text sửa thử ("KIỂM") biến mất khỏi object list.
- Zoom 130%: cả 3 slot 1032px, label cập nhật; ▲ về trang 2 cuộn đúng.
Ảnh: `mp-1-top` → `mp-6-ro-dblclick` (scratchpad phiên làm việc).
Fix đè chữ (mục 1) là code Rust — kiểm chứng lại trên build CI mới bằng đúng
kịch bản đã tái hiện bug (sửa span thường của dòng lẫn style rồi so bounds
các mảnh sau commit).