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
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:
@@ -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,10 +173,14 @@
|
||||
<main id="organizeGrid" class="organize-grid hidden"></main>
|
||||
|
||||
<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">
|
||||
<img id="editImg" class="edit-img" alt="trang đang sửa"/>
|
||||
<div id="editOverlay" class="edit-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+187
-4
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user