ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว

- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ
  จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้
  ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark
  ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes
  เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง
- ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin
  เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ)
  ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests)
- mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่
- Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath
  หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้
  RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
- คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ
  focus อยู่ใน input/textarea/select/contenteditable)
- STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ
  export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง
  selection (uniform scale + clamp รายแกน)
- sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-11 13:28:35 +07:00
parent 289d735bf3
commit 6fd3a1515f
19 changed files with 890 additions and 53 deletions

View file

@ -155,6 +155,28 @@ export default function AnnotationSelectionLayer({
100 - st.origBoxPct.y - st.origBoxPct.height,
);
next = translateAnnotationData(st.origData, dx, dy);
} else if ("scale" in st.origData) {
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
const rawK =
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
2;
const k = clampAxisScale(
clampAxisScale(
rawK,
st.anchorPct.x,
st.origBoxPct.x,
st.origBoxPct.x + st.origBoxPct.width,
st.origBoxPct.width,
),
st.anchorPct.y,
st.origBoxPct.y,
st.origBoxPct.y + st.origBoxPct.height,
st.origBoxPct.height,
);
next = scaleAnnotationData(st.origData, st.anchorPct, k, k);
} else {
const sx = clampAxisScale(
(cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS),

View file

@ -18,6 +18,7 @@ import {
ArrowRight,
Bold,
BookmarkPlus,
Brush,
Check,
ChevronDown,
ChevronLeft,
@ -86,6 +87,7 @@ import {
annotationTypeLabel,
DRAW_PENS,
effectiveDrawOpacity,
sanitizePdfText,
STAMP_COLOR,
STAMP_DEFAULT_SCALE_PCT,
STAMP_PRESETS,
@ -164,9 +166,11 @@ type BookmarkAction = { kind: "remove"; id: string };
const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"];
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ยุบ whitespace และตัดยาวไม่เกิน 120 ตัวอักษร */
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
* และตัดยาวไม่เกิน 120 ตัวอักษร */
function normalizeBookmarkTitle(s: string): string {
const t = s.replace(/\s+/g, " ").trim();
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
return t.length > 120 ? `${t.slice(0, 120)}…` : t;
}
@ -197,6 +201,142 @@ function squigglePath(w: number): string {
return d;
}
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากเมาส์จริง —
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
* จึงใช้จุด mousedown + ตำแหน่งเมาส์ล่าสุด (client coords) เป็น ground truth แทน
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
* บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง */
type ClientPt = { x: number; y: number };
function rectsInSweptPath(rects: DOMRect[], start: ClientPt, end: ClientPt): DOMRect[] {
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
let top: ClientPt;
let bottom: ClientPt;
if (Math.abs(end.y - start.y) < lineTol) {
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
} else {
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
}
return rects.filter((r) => {
const cy = r.y + r.height / 2;
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (!moved) return true;
const inTop = Math.abs(cy - top.y) <= lineTol;
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
return true; // บรรทัดกลาง: ครอบทั้งบรรทัด
});
}
/** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ
* rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */
function isThaiCombiningMark(c: string): boolean {
const v = c.charCodeAt(0);
return v === 0x0e31 || (v >= 0x0e34 && v <= 0x0e3a) || (v >= 0x0e47 && v <= 0x0e4e);
}
/** ข้อความเฉพาะส่วนที่อยู่ใน rects ที่กรองแล้ว — DOM range ของ textLayer ไทยครอบ
* ข้อความเกินบริเวณที่ลากจริง selection.toString() ดิบเลยได้คำเกินมา (rects ถูกกรอง
* ด้วย rectsInSweptPath แต่ข้อความยังไม่) จึงเดินทีละตัวอักษรใน text nodes ของ range
* เช็คว่า rect ของตัวอักษรนั้นตกใน rect ที่ render บนจอไหม — ข้อความตรงกับที่เห็น
* จากนั้นเรียงตามตำแหน่ง rect (บน→ล่าง, ซ้าย→ขวา) เพราะ DOM order ของ span ใน PDF
* ไทยบางไฟล์ไม่ตรงลำดับบนจอ แล้วปิดท้ายด้วย sanitizePdfText ตัด control char ขยะ
* แพงกว่า toString() มาก ใช้เฉพาะตอน mouseup/copy เท่านั้น (live preview เรียกถี่เกิน) */
const TEXT_PROBE_MAX_CHARS = 3000;
function selectionTextWithin(kept: DOMRect[], range: Range): string {
const raw = range.toString();
// เลือกยาวเกินขีดนี้ = คุ้มกว่าใช้ข้อความดิบ (กันหน่วงตอนปล่อยเมาส์)
if (raw.length > TEXT_PROBE_MAX_CHARS) return sanitizePdfText(raw.slice(0, 5000));
const ancestor = range.commonAncestorContainer;
const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode : ancestor;
if (!root) return sanitizePdfText(raw.slice(0, 5000));
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const probe = document.createRange();
const eps = 1; // ทนพลาดจากการปัดทศนิยมของ client rects
// ตัวอักษรที่เลือก: rect ที่ hit (ใช้เป็นกลุ่มบรรทัด) + ตำแหน่ง x + ลำดับ DOM
// (สำรองตอนจัดเรียง — combining mark ที่ไม่มี rect เอา x ของตัวฐานก่อนหน้า)
type Entry = { ch: string; rect: number; left: number; dom: number };
const entries: Entry[] = [];
let dom = 0;
let prevRect = -1;
let prevLeft = 0;
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const node = n as Text;
if (!range.intersectsNode(node)) continue;
const s = node === range.startContainer ? range.startOffset : 0;
const e = node === range.endContainer ? range.endOffset : node.data.length;
for (let i = s; i < e && entries.length < 6000; i++) {
const ch = node.data[i];
probe.setStart(node, i);
probe.setEnd(node, i + 1);
const r = probe.getClientRects()[0];
let hit = -1;
if (r !== undefined) {
hit = kept.findIndex(
(k) =>
r.left < k.right + eps &&
r.right > k.left - eps &&
r.top < k.bottom + eps &&
r.bottom > k.top - eps,
);
}
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
if (hit < 0 && sticky) hit = prevRect;
if (hit < 0) {
prevRect = -1;
continue;
}
entries.push({
ch,
rect: hit,
left: hit === prevRect && sticky ? prevLeft : r ? r.left : 0,
dom: dom++,
});
prevRect = hit;
prevLeft = entries[entries.length - 1].left;
}
}
if (entries.length === 0) return sanitizePdfText(raw.slice(0, 5000));
// เรียงกลุ่ม rect ตามตำแหน่งบนจอ: คนละบรรทัด (กลางแกน y ห่างเกินความสูงบรรทัด)
// เรียงบน→ล่าง, บรรทัดเดียวกันเรียงซ้าย→ขวา — DOM order ใช้เป็น tie-break เท่านั้น
const midY = (r: DOMRect) => r.top + r.height / 2;
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
const ra = kept[a];
const rb = kept[b];
if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) {
return midY(ra) - midY(rb);
}
return ra.left - rb.left;
});
let out = "";
let prev = -1;
for (const ri of rectOrder) {
if (prev >= 0) {
const a = kept[prev];
const b = kept[ri];
const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height);
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
}
out += entries
.filter((en) => en.rect === ri)
.sort((a, b) => a.left - b.left || a.dom - b.dom)
.map((en) => en.ch)
.join("");
prev = ri;
}
// กรองแล้วว่างเปล่า (เรขาคณิตผิดปกติ) = ใช้ข้อความดิบดีกว่าไม่มีเลย
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
}
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
@ -216,10 +356,11 @@ const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#334155" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>',
)}") 2 17, auto`;
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS
* (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
pencil: Pencil,
highlighter: Highlighter,
highlighter: Brush,
};
/** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */
@ -389,6 +530,7 @@ function PdfPage({
pageBaseWidth,
liveMark,
liveMarkStyle,
selectPreview,
annotations,
replies,
openPinId,
@ -435,6 +577,9 @@ function PdfPage({
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
/** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
selectPreview: { pageIndex: number; rects: Rect[] } | null;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
pageBaseWidth: number;
annotations: AnnotationDTO[];
@ -662,6 +807,19 @@ function PdfPage({
liveMarkStyle.opacity,
),
)}
{/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน
สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */}
{selectPreview &&
selectPreview.pageIndex === pageNumber - 1 &&
selectPreview.rects.map((r, i) =>
renderMark(
`selprev-${i}`,
rectToPx(r, { width, height }),
"#3b82f6",
"highlight",
0.3,
),
)}
</div>
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
@ -933,6 +1091,8 @@ export default function PDFViewer({
const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
// tool เลือกข้อความ — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
const [composer, setComposer] = useState<Composer | null>(null);
const [draft, setDraft] = useState("");
const [openPinId, setOpenPinId] = useState<string | null>(null);
@ -1068,7 +1228,17 @@ export default function PDFViewer({
(async () => {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
const doc = await pdfjs.getDocument({ url: file.pdfUrl }).promise;
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
let data: ArrayBuffer;
try {
const res = await fetch(`/api/pdf?fileId=${file.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.arrayBuffer();
} catch {
data = await (await fetch(file.pdfUrl)).arrayBuffer();
}
const doc = await pdfjs.getDocument({ data }).promise;
loadedDoc = doc;
if (cancelled) {
void doc.destroy();
@ -1096,7 +1266,7 @@ export default function PDFViewer({
cancelled = true;
if (loadedDoc) void loadedDoc.destroy();
};
}, [file.pdfUrl]);
}, [file.id, file.pdfUrl]);
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
@ -1230,10 +1400,28 @@ export default function PDFViewer({
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
// contenteditable (PLAN-FEAT.md §3 F3, §5)
const shortcutHandlersRef = useRef({ switchTool, openBookmarkComposer, undoHistory, redoHistory });
const shortcutHandlersRef = useRef({
switchTool,
openBookmarkComposer,
undoHistory,
redoHistory,
deleteSelected: () => {},
});
useEffect(() => {
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
shortcutHandlersRef.current = { switchTool, openBookmarkComposer, undoHistory, redoHistory };
shortcutHandlersRef.current = {
switchTool,
openBookmarkComposer,
undoHistory,
redoHistory,
// DEL/Backspace = ลบรายการที่เลือก (ของตัวเองเท่านั้น — เงื่อนไขเดียวกับปุ่มลบในแผง selection)
deleteSelected: () => {
if (selectedId === null || !user) return;
const target = annotations.find((a) => a.id === selectedId);
if (!target || target.userId !== user.userId) return;
void handleDeleteAnnotation(selectedId);
},
};
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@ -1269,7 +1457,17 @@ export default function PDFViewer({
}
if (e.altKey || inForm) return;
switch (e.key.toLowerCase()) {
const k = e.key.toLowerCase();
if (k === "delete" || k === "backspace") {
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
h.deleteSelected();
return;
}
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กด B เปิด popup bookmark แล้ว
// React focus input ทันทีใน keydown เดียวกัน browser จึงพิมพ์ "b" ตามลงไป
if (k !== "h" && k !== "d" && k !== "c" && k !== "b") return;
e.preventDefault();
switch (k) {
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
h.switchTool("highlight");
break;
@ -1511,6 +1709,25 @@ export default function PDFViewer({
/** ข้อความที่เพิ่งคลุมบนหน้าล่าสุด — ชื่อ default ของ popup bookmark (จาก mouseup บนหน้า) */
const lastSelectionTextRef = useRef("");
/** เส้นทางลากเมาส์ของ tool highlight/select (client coords) — จุดกดเมาส์ + ตำแหน่ง
* ล่าสุด ใช้เป็น ground truth กรอง selection rects (DOM order ของ textLayer บาง PDF
* มั่วจน range ครอบบรรทัดที่ไม่ได้ลากผ่าน) — เคลียร์ตอนปล่อยเมาส์ (document mouseup
* ยิงหลัง React onMouseUp ของ page เพราะ root อยู่ใต้ document) */
const selDragStartRef = useRef<{ x: number; y: number } | null>(null);
const selDragEndRef = useRef<{ x: number; y: number } | null>(null);
/** เส้นทางลากล่าสุด (px อิงหน้า) + ข้อความดิบ/ความกว้างหน้า ณ mouseup — ให้ event
* copy นำไปกรองแนวที่ลากจริงแบบเดียวกับตอนสร้างมาร์ก (DOM range ของ textLayer
* ไทยครอบ span เกินบริเวณที่เห็น Ctrl+C เลยได้ข้อความเกินมา) — เทียบข้อความ
* ดิบ + ความกว้างหน้าก่อนใช้ กันข้อมูลเก่าหลัง selection/zoom เปลี่ยน */
const lastSweepRef = useRef<{
pageIndex: number;
start: ClientPt;
end: ClientPt;
pageWidth: number;
raw: string;
} | null>(null);
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
function bookmarkDepthOf(b: BookmarkDTO): number {
@ -1647,8 +1864,10 @@ export default function PDFViewer({
// ─── Text selection → highlight (PLAN.md §6.1) ─────────────────────────────
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก */
function readTextSelection(pageIndex: number) {
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก — ข้อความ (กรองให้เหลือ
* เฉพาะตัวอักษรใน rects ที่เห็นบนจอ) คำนวณเฉพาะเมื่อ withText เพราะแพง ส่วน live
* preview ที่เรียกถี่ระหว่างลากใช้แค่ rects */
function readTextSelection(pageIndex: number, opts?: { withText?: boolean }) {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return null;
@ -1665,8 +1884,32 @@ export default function PDFViewer({
(r) => r.width > 0,
);
if (clientRects.length === 0) return null;
// จำกัดเฉพาะแนวเส้นทางที่ลากเมาส์จริง — DOM range ของ textLayer ที่ span เรียง
// มั่วครอบ rect ของบรรทัดอื่นที่ไม่ได้ลากผ่าน (ไม่มีเส้นทางเมาส์ เช่น เลือกด้วย
// คีย์บอร์ด = ปล่อยผ่านตามเดิม ไม่ให้พังยิ่งกว่าเดิม)
const startPt = selDragStartRef.current;
const endPt = selDragEndRef.current;
const sweptRects =
startPt && endPt
? rectsInSweptPath(clientRects, startPt, endPt)
: clientRects;
if (sweptRects.length === 0) return null;
const pageRect = pageEl.getBoundingClientRect();
const rectsPct = clientRects.map((r) =>
// จำเส้นทางลาก (px อิงหน้า) ไว้ให้ event copy ใช้กรองแบบเดียวกันนี้ — ครอบ
// ความกว้างหน้าด้วยเพื่อการ์ดตอน zoom เปลี่ยนหลังจากนี้ (ค่า px อิงหน้าจะสเกลตาม)
if (opts?.withText) {
lastSweepRef.current =
startPt && endPt
? {
pageIndex,
start: { x: startPt.x - pageRect.left, y: startPt.y - pageRect.top },
end: { x: endPt.x - pageRect.left, y: endPt.y - pageRect.top },
pageWidth: pageRect.width,
raw: selection.toString(),
}
: null; // เลือกด้วยคีย์บอร์ด (ไม่มีเส้นทาง) — copy ใช้ rect ของ range ตรง ๆ
}
const rectsPct = sweptRects.map((r) =>
rectToPercent(
{
x: r.left - pageRect.left,
@ -1679,24 +1922,28 @@ export default function PDFViewer({
);
return {
rects: mergeRectsByLine(rectsPct), // รวบเหลือ 1 rect ต่อบรรทัดก่อนบันทึก
// ตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4) — คลุมทั้งหน้า
// ของเอกสารหนาแน่นแล้ว action ปฏิเสธ ทำให้ไฮไลต์หายเงียบ (text ใช้แค่ค้นหา/preview)
text: selection.toString().slice(0, 5000),
first: clientRects[0],
// ข้อความกรองให้เหลือเฉพาะตัวอักษรใน rects ข้างบน (DOM range ไทยครอบคำเกิน
// บริเวณที่ลาก) และตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4)
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
first: sweptRects[0],
};
}
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
// tool เลือกข้อความ — ใช้กลไกเดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์)
useEffect(() => {
if (tool !== "highlight") {
if (tool !== "highlight" && tool !== "select") {
setLiveMark(null);
setSelectPreview(null);
return;
}
const onSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setLiveMark(null);
setSelectPreview(null);
return;
}
const node = selection.getRangeAt(0).commonAncestorContainer;
@ -1704,11 +1951,13 @@ export default function PDFViewer({
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) {
setLiveMark(null);
setSelectPreview(null);
return;
}
const pi = Number(pageEl.dataset.page);
const sel = readTextSelection(pi);
setLiveMark((prev) => {
const setPreview = tool === "highlight" ? setLiveMark : setSelectPreview;
setPreview((prev) => {
const next = sel ? { pageIndex: pi, rects: sel.rects } : null;
// ค่าเดิมทุกอย่าง = คืน object เดิมให้ React ข้าม re-render (ลากทีละนิดไม่หนัก)
const same =
@ -1727,19 +1976,70 @@ export default function PDFViewer({
});
};
document.addEventListener("selectionchange", onSelectionChange);
return () => document.removeEventListener("selectionchange", onSelectionChange);
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
// onMouseUp ของหน้า (root ของ React อยู่ใต้ document) ค่าจึงยังใช้ได้ตอนสร้างมาร์ก
const onMouseUpDoc = () => {
selDragStartRef.current = null;
selDragEndRef.current = null;
};
document.addEventListener("mouseup", onMouseUpDoc);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("mouseup", onMouseUpDoc);
};
}, [tool]);
// Copy (Ctrl+C) ข้อความจาก textLayer — ข้อความดิบของ PDF ไทยบางไฟล์เพี้ยน (glyph
// ต่อ glyph + control char ขยะ + span เรียงไม่ตรงลำดับบนจอ) และ DOM range ครอบ
// span เกินบริเวณที่ลาก จึงแทนที่คลิปบอร์ดด้วยข้อความที่กรองตามเส้นทางลากล่าสุด
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
useEffect(() => {
if (tool !== "highlight" && tool !== "select") return;
const onCopy = (e: ClipboardEvent) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const node = range.commonAncestorContainer;
const el = node instanceof Element ? node : node.parentElement;
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) return; // เฉพาะ selection ในหน้า PDF ของเรา
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
const sweep = lastSweepRef.current;
const pageRect = pageEl.getBoundingClientRect();
const fresh =
sweep !== null &&
sweep.pageIndex === Number(pageEl.dataset.page) &&
Math.abs(sweep.pageWidth - pageRect.width) < 1 &&
sweep.raw === selection.toString();
const kept =
sweep && fresh && rects.length > 0
? rectsInSweptPath(
rects,
{ x: sweep.start.x + pageRect.left, y: sweep.start.y + pageRect.top },
{ x: sweep.end.x + pageRect.left, y: sweep.end.y + pageRect.top },
)
: rects;
const text =
kept.length > 0
? selectionTextWithin(kept, range)
: sanitizePdfText(selection.toString());
e.preventDefault();
e.clipboardData?.setData("text/plain", text);
};
document.addEventListener("copy", onCopy);
return () => document.removeEventListener("copy", onCopy);
}, [tool]);
function handleMouseUpOnPage(pageIndex: number) {
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
lastSelectionTextRef.current = normalizeBookmarkTitle(
readTextSelection(pageIndex)?.text ?? "",
);
// อ่านครั้งเดียวพร้อมข้อความ (กรองตาม rects แล้ว) — bookmark กับมาร์กใช้ชุดเดียวกัน
const sel = readTextSelection(pageIndex, { withText: true });
lastSelectionTextRef.current = normalizeBookmarkTitle(sel?.text ?? "");
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
if (tool !== "highlight") return;
const sel = readTextSelection(pageIndex);
if (!sel) return;
setLiveMark(null);
window.getSelection()?.removeAllRanges();
@ -2006,8 +2306,9 @@ export default function PDFViewer({
return (
<div className="flex min-h-screen">
{/* tool highlight — ซ่อนสี selection น้ำเงินของเบราว์เซอร์ ให้เห็นแต่ preview มาร์กของเรา */}
{tool === "highlight" && (
{/* tool highlight/select — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview
มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */}
{(tool === "highlight" || tool === "select") && (
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
)}
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
@ -2748,7 +3049,19 @@ export default function PDFViewer({
style={{ width: w, height: h }}
onMouseUp={() => handleMouseUpOnPage(i)}
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
onMouseMove={(e) => handlePageHoverMove(e, i)}
onMouseDown={(e) => {
// จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects)
if (tool !== "highlight" && tool !== "select") return;
selDragStartRef.current = { x: e.clientX, y: e.clientY };
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}}
onMouseMove={(e) => {
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
if ((tool === "highlight" || tool === "select") && e.buttons === 1) {
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}
handlePageHoverMove(e, i);
}}
onMouseLeave={() => setHoveredId(null)}
>
{mounted && pdfjs && doc ? (
@ -2772,6 +3085,7 @@ export default function PDFViewer({
? { opacity: highlightOpacityPct / 100 }
: {}),
}}
selectPreview={selectPreview}
annotations={visibleAnnotations
.filter((a) => a.pageIndex === i)
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม

View file

@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, STAMP_FONT_RATIO } from "@/utils/annotation-display";
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
@ -20,6 +20,20 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData };
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
function measureStampText(text: string, fontSize: number): number {
if (typeof document === "undefined") return 0;
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
if (!stampMeasureCtx) return 0;
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
return stampMeasureCtx.measureText(text).width;
}
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
@ -294,7 +308,8 @@ export default function TextboxStampLayer({
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5) */}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
{stamps.map((s) => {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
@ -310,8 +325,10 @@ export default function TextboxStampLayer({
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontSize: w * STAMP_FONT_RATIO,
fontFamily: STAMP_FONT_STACK,
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
lineHeight: 1.2,
whiteSpace: "nowrap",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}