diff --git a/app/src/main.js b/app/src/main.js
index 0d9cead..c32737e 100644
--- a/app/src/main.js
+++ b/app/src/main.js
@@ -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.
diff --git a/app/src/styles.css b/app/src/styles.css
index e9df964..9905ab4 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -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; }
diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs
index 98c2787..d5fc5b3 100644
--- a/crates/ff-engine/src/edit.rs
+++ b/crates/ff-engine/src/edit.rs
@@ -1748,7 +1748,10 @@ pub fn apply_edits(
};
// 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).
- 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
.objects_mut()
.create_text_object(
@@ -1763,12 +1766,14 @@ pub fn apply_edits(
.as_text_object()
.map(|t| t.text() == p.text)
.unwrap_or(false);
+ let mut ir = None;
if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.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 {
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))
.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);
}
}
}
diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md
index e229874..e5dd9c7 100644
--- a/docs/12-phase4-summary.md
+++ b/docs/12-phase4-summary.md
@@ -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.
- 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).
+
+
+---
+
+# 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).