From 21314281a8c59075d526c6adcdd86524cf192fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Mon, 10 Aug 2026 09:52:01 +0700 Subject: [PATCH] Vong 6: edit mode NHIEU TRANG + nut Huy thay doi + fix de chu khi luu dong lan style - 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 --- app/src/index.html | 11 +- app/src/main.js | 191 ++++++++++++++++++++++++++++++++++- app/src/styles.css | 11 ++ crates/ff-engine/src/edit.rs | 20 +++- docs/12-phase4-summary.md | 67 ++++++++++++ 5 files changed, 289 insertions(+), 11 deletions(-) diff --git a/app/src/index.html b/app/src/index.html index dbc2192..3cc00b7 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -102,6 +102,7 @@ + @@ -172,9 +173,13 @@
-
- trang đang sửa -
+ +
+
+ trang đang sửa +
+
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).