ซ่อม 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:
parent
289d735bf3
commit
6fd3a1515f
19 changed files with 890 additions and 53 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 ตาม
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue