Sửa edit inline với PDF Word-export: khung theo dòng, sửa cả đoạn giữ xuống dòng, hết chồng chữ

Chẩn đoán trên file thật (Word xuất): tiêu đề 2 dòng bị cắt thành 41 run
per-word/per-char xen run rỗng kích thước 0, cỡ 22.5pt + 20pt trộn nhau,
bbox chữ có dấu (Ạ, Ợ) tụt thấp hơn hẳn chữ thường.

UI (main.js):
- clusterTextLines: gom run thành DÒNG theo GIAO DỌC ≥50% chiều cao nhỏ
  (miễn nhiễm dấu tiếng Việt) + tách cột theo hở ngang; gắn run rỗng vào dòng
  chứa nó. Overlay vẽ 1 khung/DÒNG (file thật: 231 ô vụn → 18 khung);
  chọn/kéo/xoá/đổi thuộc tính tác động CẢ DÒNG (batch ops, editSelRuns)
- paragraphLines mới trên nền cluster: dòng kề nhau (hở ≤1.3× chiều cao),
  giao ngang ≥30%, cỡ chữ ≤1.8× (tiêu đề 22.5/20pt vẫn 1 đoạn), khoá nhịp ±35%
- Đúp LUÔN mở ô sửa CẢ ĐOẠN, mỗi dòng gốc = 1 dòng textarea (giữ và sửa được
  cả xuống dòng); đúp trúng KHE giữa các từ vẫn mở (hit-test theo điểm trên
  overlay); click/chọn chữ trong ô sửa di chuyển con trỏ, KHÔNG thoát edit
  (stopPropagation + guard .edit-inline ở onEditStageClick); ô che kín chữ cũ,
  line-height đúng nhịp dòng, scrollIntoView

Engine (edit.rs ReflowText):
- TỰ NỞ danh sách run: mọi text object có tâm trong union bbox (+2pt) của các
  run được đưa đều bị thay — không bao giờ sót run rỗng/lệch bbox → hết cảnh
  chữ mới chồng chữ cũ
- Phát hiện khối CĂN GIỮA (tâm mọi dòng ≈ tâm khối, có dòng thụt trái) →
  dòng mới đặt x = tâm khối − w/2 (đo hmtx) — tiêu đề căn giữa giữ căn giữa

Kiểm chứng với file thật (cục bộ): 39 run tiêu đề xoá sạch 0 mảnh sót, 2 dòng
mới căn giữa lệch tâm trang <5pt, extract sạch chữ cũ; grouping đúng khi đúp
mọi từ của cả 2 dòng lẫn header 8pt per-char.

Test: 2 test hồi quy mới (nở indices bắt run giữa bị sót; giữ căn giữa đo
bằng fixture tự căn) → edit 19/19, toàn bộ engine 75/75 ngoài qpdf-fixture.
Docs: 12 (mục vá theo phản hồi), 13 (E34–E38).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-16 10:49:59 +00:00
co-authored by Claude Fable 5
parent 77ed917913
commit c8f22b091f
5 changed files with 470 additions and 218 deletions
+210 -208
View File
@@ -33,7 +33,8 @@ const state = {
editUndo: [], // stack đường dẫn editBase trước mỗi op
editRedo: [],
editObjects: [], // ObjectInfo của editPage (đọc lại từ editBase sau mỗi op)
editSel: null, // index object đang chọn
editSel: null, // index object đại diện đang chọn
editSelRuns: [], // mọi run thuộc khung đang chọn (dòng text = nhiều run)
editScale: 1, // px/pt của ảnh stage
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
@@ -2181,28 +2182,45 @@ function buildEditOverlay() {
const ov = $("editOverlay");
ov.innerHTML = "";
ov.classList.toggle("armed", !!state.editArm);
const img = $("editImg");
// Khớp kích thước overlay với ảnh thực tế hiển thị.
state.editObjects.forEach((o) => {
if (o.kind === "path" || o.kind === "shading") return; // path/shading: chưa cho chọn ở v1
// TEXT: 1 khung mỗi DÒNG thị giác (như Foxit) — không vẽ ô chi chít từng
// run khi PDF cắt dòng thành từng từ/ký tự. Chọn/kéo/xoá tác động CẢ DÒNG.
const lines = clusterTextLines(state.editObjects);
lines.forEach((line) => {
const box = document.createElement("div");
box.className = "edit-box kind-" + o.kind + (state.editSel === o.index ? " selected" : "");
box.className = "edit-box kind-text";
const rep = line.runs.find((r) => (r.text || "").trim()) || line.runs[0];
box.dataset.index = String(rep.index);
box.dataset.runs = JSON.stringify(line.runs.map((r) => r.index));
Object.assign(box.style, editBoxStyle(line.rect));
box.title = composeLineText(line.runs);
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, rep, line.runs));
box.addEventListener("click", (e) => {
if (state.editArm) return;
e.stopPropagation();
selectEditObject(rep.index, line.runs.map((r) => r.index));
});
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(rep); });
ov.appendChild(box);
});
// Ảnh/form: khung theo từng object như cũ (có resize).
state.editObjects.forEach((o) => {
if (o.kind !== "image" && o.kind !== "form") return;
const box = document.createElement("div");
box.className = "edit-box kind-" + o.kind;
box.dataset.index = String(o.index);
Object.assign(box.style, editBoxStyle(o.rect));
box.title = o.kind === "text" ? (o.text || "") : o.kind;
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o));
box.title = o.kind;
box.addEventListener("mousedown", (e) => onEditBoxMouseDown(e, o, [o.index]));
box.addEventListener("click", (e) => {
if (state.editArm) return; // đang chờ đặt chữ/ảnh → để click rơi xuống overlay
if (state.editArm) return;
e.stopPropagation();
selectEditObject(o.index);
selectEditObject(o.index, [o.index]);
});
if (o.kind === "text") {
box.addEventListener("dblclick", (e) => { e.stopPropagation(); startTextEdit(o); });
}
ov.appendChild(box);
});
refreshEditSelection();
void img;
}
// Cập nhật trạng thái chọn TẠI CHỖ (không dựng lại overlay) — quan trọng để
@@ -2213,20 +2231,24 @@ function refreshEditSelection() {
const isSel = Number(box.dataset.index) === state.editSel;
box.classList.toggle("selected", isSel);
const existing = box.querySelector(".ed-handle");
if (isSel && !existing) {
const o = state.editObjects.find((x) => x.index === state.editSel);
const o = state.editObjects.find((x) => x.index === Number(box.dataset.index));
const resizable = o && (o.kind === "image" || o.kind === "form");
if (isSel && resizable && !existing) {
const h = document.createElement("div");
h.className = "ed-handle";
h.addEventListener("mousedown", (e) => onEditResizeMouseDown(e, o));
box.appendChild(h);
} else if (!isSel && existing) {
} else if ((!isSel || !resizable) && existing) {
existing.remove();
}
});
}
function selectEditObject(index) {
// Chọn 1 khung. `runIndices`: mọi run thuộc khung (dòng text = nhiều run) —
// thao tác xoá/kéo/đổi thuộc tính áp cho TẤT CẢ.
function selectEditObject(index, runIndices) {
state.editSel = index != null && index >= 0 ? index : null;
state.editSelRuns = state.editSel != null ? (runIndices || [state.editSel]) : [];
const o = state.editObjects.find((x) => x.index === state.editSel);
const isText = o && o.kind === "text";
const isImage = o && o.kind === "image";
@@ -2315,36 +2337,6 @@ function cssFontStack(family) {
return `"${family}", ${generic}`;
}
// Gom các text run CÙNG DÒNG với `o` (baseline xấp xỉ, liền kề theo chiều
// ngang) — PDF hay cắt 1 dòng nhìn thấy thành nhiều run; Foxit cho sửa cả
// dòng nên ta cũng vậy. Trả về mảng run đã xếp trái→phải (luôn chứa `o`).
function sameLineRuns(o) {
const fs = o.font_size || 12;
const tol = Math.max(1.5, fs * 0.25);
const cands = state.editObjects
.filter(
(x) =>
x.kind === "text" &&
Math.abs(x.rect.bottom - o.rect.bottom) <= tol &&
(x.font_size || 12) <= fs * 2.2 &&
(x.font_size || 12) >= fs / 2.2
)
.sort((a, b) => a.rect.left - b.rect.left);
const i0 = cands.findIndex((x) => x.index === o.index);
if (i0 < 0) return [o];
const gapMax = Math.max(fs * 1.2, 6); // hở quá 1.2em coi như cột khác
const runs = [cands[i0]];
for (let i = i0 - 1; i >= 0; i--) {
if (runs[0].rect.left - cands[i].rect.right <= gapMax) runs.unshift(cands[i]);
else break;
}
for (let i = i0 + 1; i < cands.length; i++) {
if (cands[i].rect.left - runs[runs.length - 1].rect.right <= gapMax) runs.push(cands[i]);
else break;
}
return runs;
}
// Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ.
function composeLineText(runs) {
let s = "";
@@ -2360,86 +2352,137 @@ function composeLineText(runs) {
return s;
}
// Gom ĐOẠN VĂN nhiều dòng quanh run `o` (iteration 3): các dòng baseline cách
// đều (±25%), cỡ chữ tương đồng, giao nhau theo chiều ngang. Trả về mảng dòng
// trên→dưới, mỗi dòng = mảng run trái→phải.
function paragraphLines(o) {
const fs = o.font_size || 12;
const boundsOf = (runs) => ({
left: Math.min(...runs.map((r) => r.rect.left)),
right: Math.max(...runs.map((r) => r.rect.right)),
bottom: runs[0].rect.bottom,
});
const overlaps = (a, b) =>
Math.min(a.right, b.right) - Math.max(a.left, b.left) >
0.3 * Math.min(a.right - a.left, b.right - b.left);
// Gom TOÀN BỘ text run của trang thành các DÒNG thị giác. PDF từ Word hay cắt
// 1 dòng thành run theo TỪNG TỪ/KÝ TỰ xen run rỗng, bbox chữ có dấu (Ạ, Ợ)
// tụt thấp hơn hẳn — nên cluster theo GIAO DỌC (≥50% chiều cao nhỏ hơn) thay
// vì so baseline, rồi tách cột theo khoảng hở ngang lớn. Trả mảng dòng
// {runs (trái→phải, gồm cả run rỗng trong tầm), rect (union), fs (cỡ lớn nhất)}.
function clusterTextLines(objs) {
const texts = objs.filter((o) => o.kind === "text");
const real = texts.filter((o) => o.rect.top - o.rect.bottom > 0.5 && o.rect.right - o.rect.left > 0.1);
const degenerate = texts.filter((o) => !real.includes(o)); // run rỗng/điểm
const base = sameLineRuns(o);
const used = new Set(base.map((r) => r.index));
const lines = [base];
const cands = state.editObjects.filter(
(x) =>
x.kind === "text" &&
!used.has(x.index) &&
(x.font_size || 12) <= fs * 1.6 &&
(x.font_size || 12) >= fs / 1.6
);
let advance = null; // khoảng baseline đã chốt của đoạn
const extend = (dir) => {
// dir = -1: mở rộng XUỐNG (bottom giảm); +1: mở rộng LÊN.
for (;;) {
const edge = dir < 0 ? lines[lines.length - 1] : lines[0];
const eb = boundsOf(edge);
let best = null;
let bestGap = Infinity;
for (const r of cands) {
if (used.has(r.index)) continue;
const gap = dir < 0 ? eb.bottom - r.rect.bottom : r.rect.bottom - eb.bottom;
if (gap < fs * 0.6 || gap > fs * 2.6) continue; // quá sát/quá xa = không cùng đoạn
if (advance && (gap < advance * 0.75 || gap > advance * 1.25)) continue;
const line = sameLineRuns(r);
if (!overlaps(eb, boundsOf(line))) continue;
if (gap < bestGap) {
bestGap = gap;
best = line;
}
}
if (!best) break;
best.forEach((r) => used.add(r.index));
if (dir < 0) lines.push(best);
else lines.unshift(best);
if (advance == null) advance = bestGap;
// (1) Cụm theo giao dọc.
const bands = [];
for (const r of real.slice().sort((a, b) => b.rect.bottom - a.rect.bottom)) {
const h = r.rect.top - r.rect.bottom;
const band = bands.find((bd) => {
const ov = Math.min(bd.top, r.rect.top) - Math.max(bd.bottom, r.rect.bottom);
return ov > 0.5 * Math.min(bd.top - bd.bottom, h);
});
if (band) {
band.runs.push(r);
band.top = Math.max(band.top, r.rect.top);
band.bottom = Math.min(band.bottom, r.rect.bottom);
} else {
bands.push({ top: r.rect.top, bottom: r.rect.bottom, runs: [r] });
}
};
extend(-1);
extend(+1);
}
// (2) Trong mỗi cụm dọc: xếp trái→phải, tách dòng/cột khi hở quá lớn.
const lines = [];
for (const band of bands) {
band.runs.sort((a, b) => a.rect.left - b.rect.left);
let cur = null;
for (const r of band.runs) {
const fs = r.font_size || 12;
const gapMax = Math.max(fs * 1.7, 10);
if (cur && r.rect.left - cur.rect.right <= gapMax) {
cur.runs.push(r);
cur.rect.right = Math.max(cur.rect.right, r.rect.right);
cur.rect.top = Math.max(cur.rect.top, r.rect.top);
cur.rect.bottom = Math.min(cur.rect.bottom, r.rect.bottom);
cur.fs = Math.max(cur.fs, fs);
} else {
cur = { runs: [r], fs, rect: { left: r.rect.left, right: r.rect.right, top: r.rect.top, bottom: r.rect.bottom } };
lines.push(cur);
}
}
}
// (3) Gắn run rỗng vào dòng chứa nó (để khi sửa xoá sạch, không sót).
for (const d of degenerate) {
const line = lines.find(
(l) =>
d.rect.bottom >= l.rect.bottom - 3 &&
d.rect.bottom <= l.rect.top + 3 &&
d.rect.left >= l.rect.left - 6 &&
d.rect.left <= l.rect.right + 6
);
if (line) line.runs.push(d);
}
for (const l of lines) l.runs.sort((a, b) => a.rect.left - b.rect.left);
return lines;
}
// Double-click: đoạn ≥2 dòng → sửa CẢ ĐOẠN với reflow; 1 dòng → sửa dòng.
function startTextEdit(o) {
const lines = paragraphLines(o);
if (lines.length >= 2) startBlockTextEdit(o, lines);
else startInlineTextEdit(o);
// Gom ĐOẠN VĂN nhiều dòng quanh run `o`: các dòng kề nhau theo chiều dọc
// (khoảng hở ≤ 1.3× chiều cao dòng), giao ngang ≥30%, cỡ chữ tương đồng
// (≤1.8× — tiêu đề 2 dòng 22.5/20pt vẫn là 1 đoạn). Trả mảng dòng trên→dưới.
function paragraphLines(o) {
const all = clusterTextLines(state.editObjects);
const mine = all.find((l) => l.runs.some((r) => r.index === o.index));
if (!mine) return [ { runs: [o], fs: o.font_size || 12, rect: { ...o.rect } } ];
const hOf = (l) => Math.max(1, l.rect.top - l.rect.bottom);
const overlaps = (a, b) =>
Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left) >
0.3 * Math.min(a.rect.right - a.rect.left, b.rect.right - b.rect.left);
const sizeOk = (a, b) => {
const r = a.fs / b.fs;
return r <= 1.8 && r >= 1 / 1.8;
};
const sorted = all.slice().sort((a, b) => b.rect.bottom - a.rect.bottom); // trên→dưới
const iMine = sorted.indexOf(mine);
const block = [mine];
let advance = null;
// Mở rộng XUỐNG rồi LÊN, khoá nhịp khoảng cách dòng ±35% sau lần đầu.
const tryDir = (step) => {
let edge = mine;
for (let i = iMine + step; i >= 0 && i < sorted.length; i += step) {
const cand = sorted[i];
const gap = step > 0 ? edge.rect.bottom - cand.rect.top : cand.rect.bottom - edge.rect.top;
const maxGap = 1.3 * Math.max(hOf(edge), hOf(cand));
if (gap < -3 || gap > maxGap) break;
if (!overlaps(edge, cand) || !sizeOk(edge, cand)) break;
const adv = Math.abs(edge.rect.bottom - cand.rect.bottom);
if (advance && (adv < advance * 0.65 || adv > advance * 1.35)) break;
if (advance == null) advance = adv;
if (step > 0) block.push(cand);
else block.unshift(cand);
edge = cand;
}
};
tryDir(+1);
tryDir(-1);
return block.map((l) => l); // mỗi phần tử: {runs, fs, rect}
}
// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ khối, gõ tự chảy
// dòng; commit → engine reflow (bẻ dòng lại theo bề rộng khối, giữ font).
// Double-click vào chữ: LUÔN mở ô sửa CẢ ĐOẠN (kể cả đoạn 1 dòng) — một
// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow.
function startTextEdit(o) {
startBlockTextEdit(o, paragraphLines(o));
}
// Sửa cả đoạn "như Word" (Foxit Edit Text): textarea phủ kín khối (che chữ
// cũ bên dưới), MỖI DÒNG GỐC = 1 dòng textarea (giữ xuống dòng — sửa được cả
// vị trí ngắt dòng); click trong ô = di chuyển con trỏ; commit → engine
// reflow: xoá SẠCH mọi run của khối (kể cả run sót — engine tự nở theo bbox),
// giữ font + giữ căn giữa nếu khối gốc căn giữa.
function startBlockTextEdit(o, lines) {
const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove();
const allRuns = lines.flat();
const allRuns = lines.flatMap((l) => l.runs);
const union = {
left: Math.min(...allRuns.map((r) => r.rect.left)),
right: Math.max(...allRuns.map((r) => r.rect.right)),
bottom: Math.min(...allRuns.map((r) => r.rect.bottom)),
top: Math.max(...allRuns.map((r) => r.rect.top)),
left: Math.min(...lines.map((l) => l.rect.left)),
right: Math.max(...lines.map((l) => l.rect.right)),
bottom: Math.min(...lines.map((l) => l.rect.bottom)),
top: Math.max(...lines.map((l) => l.rect.top)),
};
// Đoạn = 1 dòng chảy; Enter của người dùng = ngắt cứng.
const original = lines.map(composeLineText).join(" ");
// Giữ đúng cấu trúc dòng gốc: mỗi dòng thị giác = 1 dòng trong ô sửa.
const original = lines.map((l) => composeLineText(l.runs)).join("\n");
const ta = document.createElement("textarea");
ta.className = "edit-inline edit-inline-block";
@@ -2447,11 +2490,14 @@ function startBlockTextEdit(o, lines) {
Object.assign(ta.style, editBoxStyle(union));
const advPt =
lines.length > 1
? lines[0][0].rect.bottom - lines[1][0].rect.bottom
? Math.abs(lines[0].rect.bottom - lines[1].rect.bottom)
: (o.font_size || 12) * 1.25;
const advPx = Math.max(8, advPt * state.editScale);
// Chừa 2 dòng trống để gõ thêm.
ta.style.height = parseFloat(ta.style.height) + 2 * advPx + "px";
// Cao đủ số dòng + 1 dòng trống để gõ thêm; rộng thêm chút để gõ dài hơn.
ta.style.height = (lines.length + 1) * advPx + 6 + "px";
const p = state.pages[state.editPage];
const maxW = (p.widthPt - union.left) * state.editScale - 4;
ta.style.width = Math.min(maxW, parseFloat(ta.style.width) + 60) + "px";
ta.style.lineHeight = advPx + "px";
ta.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px";
ta.style.fontFamily = cssFontStack(o.font_family);
@@ -2461,8 +2507,9 @@ function startBlockTextEdit(o, lines) {
ov.appendChild(ta);
ta.focus();
ta.setSelectionRange(0, 0);
ta.scrollIntoView({ block: "center", behavior: "instant" });
$("editHint").textContent =
"Sửa cả đoạn — Enter: xuống dòng cứng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ";
"Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ";
let done = false;
const commit = (save) => {
@@ -2485,102 +2532,44 @@ function startBlockTextEdit(o, lines) {
else if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); commit(true); }
e.stopPropagation();
});
// Click/chọn chữ TRONG ô sửa không được lan xuống overlay (gây thoát edit).
ta.addEventListener("mousedown", (e) => e.stopPropagation());
ta.addEventListener("click", (e) => e.stopPropagation());
ta.addEventListener("dblclick", (e) => e.stopPropagation());
ta.addEventListener("blur", () => commit(true));
}
// Sửa text tại chỗ kiểu Foxit: gộp cả dòng, ô sửa hiển thị đúng font/cỡ/màu,
// Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph).
function startInlineTextEdit(o) {
const ov = $("editOverlay");
const existing = ov.querySelector(".edit-inline");
if (existing) existing.remove();
const runs = sameLineRuns(o);
const original = composeLineText(runs);
const union = {
left: Math.min(...runs.map((r) => r.rect.left)),
right: Math.max(...runs.map((r) => r.rect.right)),
bottom: Math.min(...runs.map((r) => r.rect.bottom)),
top: Math.max(...runs.map((r) => r.rect.top)),
};
const inp = document.createElement("input");
inp.className = "edit-inline";
inp.value = original;
const st = editBoxStyle(union);
Object.assign(inp.style, st);
// Chừa chỗ gõ thêm về bên phải (không tràn khung trang).
const p = state.pages[state.editPage];
const maxW = (p.widthPt - union.left) * state.editScale - 4;
inp.style.width = Math.min(maxW, parseFloat(st.width) + 220) + "px";
// WYSIWYG khi gõ: đúng font/cỡ/màu/kiểu của run đầu.
inp.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px";
inp.style.fontFamily = cssFontStack(o.font_family);
inp.style.fontWeight = o.font_bold ? "bold" : "normal";
inp.style.fontStyle = o.font_italic ? "italic" : "normal";
if (o.color) inp.style.color = rgbCss(o.color);
ov.appendChild(inp);
inp.focus();
inp.select();
let done = false;
const commit = (save) => {
if (done) return;
done = true;
const text = inp.value;
inp.remove();
if (save && text !== original) {
// Run đầu nhận toàn bộ text mới (giữ font/cỡ/màu gốc — mọi field null),
// các run còn lại của dòng bị xoá.
const ops = [
{
op: "setText",
index: runs[0].index,
text,
fontSize: null,
color: null,
fontFamily: null,
bold: null,
italic: null,
},
...runs.slice(1).map((r) => ({ op: "delete", index: r.index })),
];
stageEditOps(ops);
}
};
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(true); }
else if (e.key === "Escape") { e.preventDefault(); commit(false); }
e.stopPropagation();
});
inp.addEventListener("blur", () => commit(true));
}
// Đổi thuộc tính chữ cho run đang chọn. `part` chỉ chứa field muốn đổi —
// mọi field khác gửi null = GIỮ NGUYÊN (engine không đụng font khi không cần).
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
// từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN.
function applyTextPropToSelected(part) {
const o = state.editObjects.find((x) => x.index === state.editSel);
if (!o || o.kind !== "text") return;
stageEditOp({
op: "setText",
index: o.index,
text: o.text || "",
fontSize: null,
color: null,
fontFamily: null,
bold: null,
italic: null,
...part,
});
const runs = state.editSelRuns
.map((i) => state.editObjects.find((x) => x.index === i))
.filter((x) => x && x.kind === "text");
if (!runs.length) return;
stageEditOps(
runs.map((r) => ({
op: "setText",
index: r.index,
text: r.text || "",
fontSize: null,
color: null,
fontFamily: null,
bold: null,
italic: null,
...part,
}))
);
}
function deleteSelectedEditObject() {
if (state.editSel == null) return;
stageEditOp({ op: "delete", index: state.editSel });
if (!state.editSelRuns.length) return;
stageEditOps(state.editSelRuns.map((i) => ({ op: "delete", index: i })));
}
// Click lên trang khi đang "armed" để đặt chữ/ảnh mới.
function onEditStageClick(e) {
// Click vào ô sửa đang mở (di chuyển con trỏ) — không được đụng overlay.
if (e.target.closest && e.target.closest(".edit-inline")) return;
if (!state.editArm) { selectEditObject(-1); state.editSel = null; buildEditOverlay(); return; }
const img = $("editImg");
const r = img.getBoundingClientRect();
@@ -2645,12 +2634,13 @@ function promptAddText(pdfX, pdfY) {
inp.addEventListener("blur", () => commit(true));
}
// Kéo để di chuyển object đang chọn — box đi theo con trỏ NGAY (live feedback
// như Foxit), thả chuột mới commit vào engine.
function onEditBoxMouseDown(e, o) {
// Kéo để di chuyển khung đang chọn (dòng text = kéo CẢ DÒNG) — box đi theo
// con trỏ NGAY (live feedback như Foxit), thả chuột mới commit vào engine.
function onEditBoxMouseDown(e, o, runs) {
if (state.editArm) return;
if (e.target.classList.contains("ed-handle")) return;
selectEditObject(o.index);
const runIndices = (runs && runs.length ? runs : [o]).map((r) => (typeof r === "number" ? r : r.index));
selectEditObject(o.index, runIndices);
const box = e.currentTarget;
const startX = e.clientX, startY = e.clientY;
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
@@ -2673,7 +2663,7 @@ function onEditBoxMouseDown(e, o) {
if (!moved) return;
const dx = (ev.clientX - startX) / state.editScale;
const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm
stageEditOp({ op: "transform", index: o.index, dx, dy, sx: 1, sy: 1 });
stageEditOps(runIndices.map((i) => ({ op: "transform", index: i, dx, dy, sx: 1, sy: 1 })));
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
@@ -3453,6 +3443,18 @@ $("edColorBtn").addEventListener("click", () => {
});
$("edSave").addEventListener("click", saveEdits);
$("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) => {
if (state.editArm) return;
if (e.target.closest && e.target.closest(".edit-inline")) return;
const img = $("editImg");
const r = img.getBoundingClientRect();
const p = state.pages[state.editPage];
const x = (e.clientX - r.left) / state.editScale;
const y = p.heightPt - (e.clientY - r.top) / state.editScale;
const hit = pickTextAt(state.editObjects, x, y);
if (hit) startTextEdit(hit);
});
// 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.