eCourt-note/components/pdf/PDFViewer.tsx

3269 lines
158 KiB
TypeScript
Raw Normal View History

"use client";
import Link from "next/link";
import {
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
startTransition,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import {
AlignCenter,
AlignLeft,
AlignRight,
ArrowRight,
BookmarkPlus,
ซ่อม 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>
2026-09-11 13:28:35 +07:00
Brush,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
ClipboardList,
Download,
Eraser,
EyeOff,
Hand,
Highlighter,
MessageSquare,
MessageSquarePlus,
Minus,
MousePointer2,
PanelLeft,
Pencil,
Redo2,
Shapes,
Square,
Trash2,
Type,
Stamp,
Undo2,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react";
import {
createAnnotation,
createBookmark,
createReply,
deleteAnnotation,
deleteBookmark,
deleteReply,
setAnnotationResolved,
updateAnnotation,
updateBookmark,
} from "@/app/actions/annotations";
import {
useAnnotationRealtime,
} from "@/hooks/useAnnotationRealtime";
import { useHistory } from "@/hooks/useHistory";
import type {
AnnotationData,
AnnotationDTO,
AnnotationType,
BookmarkDTO,
CommentData,
DrawData,
DrawPen,
HighlightData,
HighlightStyle,
TextboxSegment,
RedactionData,
ReplyDTO,
ShapeData,
ShapeKind,
StampData,
TextboxData,
} from "@/db/schema";
import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
DRAW_PENS,
effectiveDrawOpacity,
ซ่อม 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>
2026-09-11 13:28:35 +07:00
sanitizePdfText,
STAMP_COLOR,
STAMP_DEFAULT_SCALE_PCT,
STAMP_PRESETS,
TEXTBOX_DEFAULT_COLOR,
TEXTBOX_DEFAULT_FONT_SIZE_PCT,
} from "@/utils/annotation-display";
import {
clamp,
getPointerPercent,
mergeRectsByLine,
rectToPercent,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { getLocalUser, type LocalUser } from "@/utils/user";
import {
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
hitTestPath,
hitTestShape,
textboxMaxWidthPct,
} from "@/utils/geometry";
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
import AnnotationSidebar from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
ColorSwatches,
HIGHLIGHT_STYLE_BTNS,
NumberCombo,
PanelRow,
} from "./ToolControls";
type Tool =
| "select"
| "pan"
| "highlight"
| "draw"
| "comment"
| "shape"
| "erase"
| "redact"
| "textbox"
| "stamp";
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
* select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */
const TEXT_SELECTION_TOOLS = new Set<Tool>([
"select",
"highlight",
"comment",
"textbox",
"stamp",
]);
type Composer = {
pageIndex: number;
position: Point; // % ของหน้า
x: number; // ตำแหน่งจอสำหรับวางกล่อง (fixed)
y: number;
};
/** F17/B2: กล่องข้อความที่กำลังสร้างใหม่ — textarea วางบนหน้า ณ ตำแหน่งที่คลิก */
type TextboxComposer = {
pageIndex: number;
position: Point; // % ของหน้า
};
type AnnotationAction =
| { kind: "add"; row: AnnotationDTO }
| { kind: "update"; row: AnnotationDTO }
| { kind: "remove"; id: string };
/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2)
* ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน)
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */
type TextboxUI = {
composer: TextboxComposer | null;
commitComposer: (text: string, segments?: TextboxSegment[]) => void;
cancelComposer: () => void;
editingId: string | null;
startEdit: (id: string | null) => void;
commitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void;
};
/** N2: เหลือแค่ remove (create รอผลจาก action ตรง ๆ) */
type BookmarkAction = { kind: "remove"; id: string };
const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"];
ซ่อม 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>
2026-09-11 13:28:35 +07:00
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
* และตัดยาวไม่เกิน 120 ตัวอักษร */
function normalizeBookmarkTitle(s: string): string {
ซ่อม 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>
2026-09-11 13:28:35 +07:00
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
return t.length > 120 ? `${t.slice(0, 120)}…` : t;
}
/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */
function viewerHeaderHeight(): number {
return document.querySelector("header")?.offsetHeight ?? 0;
}
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
const SCROLL_BELOW_HEADER_GAP = 40;
function scrollToBelowHeader(absoluteTop: number): void {
window.scrollTo({
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
behavior: "smooth",
});
}
/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */
function squigglePath(w: number): string {
let d = "M0 2.5";
let up = true;
for (let x = 0; x + 4 <= Math.max(w, 4); x += 4) {
d += up ? " q2 -2.5 4 0" : " q2 2.5 4 0";
up = !up;
}
return d;
}
ซ่อม 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>
2026-09-11 13:28:35 +07:00
/** กรอง 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 },
{ key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "วาง comment", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
{ key: "textbox", label: "กล่องข้อความ", Icon: Type },
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
];
/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน
* แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */
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`;
ซ่อม 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>
2026-09-11 13:28:35 +07:00
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS
* (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
pencil: Pencil,
ซ่อม 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>
2026-09-11 13:28:35 +07:00
highlighter: Brush,
};
/** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */
const SHAPE_KIND_BTNS: { key: ShapeKind; label: string; Icon: LucideIcon }[] = [
{ key: "rect", label: "สี่เหลี่ยม", Icon: Square },
{ key: "ellipse", label: "วงรี", Icon: Circle },
{ key: "line", label: "เส้นตรง", Icon: Minus },
{ key: "arrow", label: "ลูกศร", Icon: ArrowRight },
];
// AnnotationDTO เป็น flat type (type/data แยกกัน) → ใช้ type predicate แคบ scope
function isHighlight(a: AnnotationDTO): a is AnnotationDTO & { type: "HIGHLIGHT"; data: HighlightData } {
return a.type === "HIGHLIGHT";
}
function isDraw(a: AnnotationDTO): a is AnnotationDTO & { type: "DRAW"; data: DrawData } {
return a.type === "DRAW";
}
function isComment(a: AnnotationDTO): a is AnnotationDTO & { type: "COMMENT"; data: CommentData } {
return a.type === "COMMENT";
}
function isShape(a: AnnotationDTO): a is AnnotationDTO & { type: "SHAPE"; data: ShapeData } {
return a.type === "SHAPE";
}
function isRedaction(a: AnnotationDTO): a is AnnotationDTO & { type: "REDACTION"; data: RedactionData } {
return a.type === "REDACTION";
}
function isTextbox(a: AnnotationDTO): a is AnnotationDTO & { type: "TEXTBOX"; data: TextboxData } {
return a.type === "TEXTBOX";
}
function isStamp(a: AnnotationDTO): a is AnnotationDTO & { type: "STAMP"; data: StampData } {
return a.type === "STAMP";
}
/** ตรวจว่าจุดคลิก (% ของหน้า) ชน annotation นี้หรือไม่ — hit-test แม่นตามชนิด
* (DRAW/SHAPE ใช้ตัวตรวจเดียวกับ eraser, HIGHLIGHT ดู rect ต่อบรรทัด,
* ที่เหลือดูจาก bbox + padding เผื่อรายการเล็ก) */
function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean {
if (a.type === "HIGHLIGHT") {
return (a.data as HighlightData).rects.some(
(r) =>
p.x >= r.x &&
p.x <= r.x + r.width &&
p.y >= r.y &&
p.y <= r.y + r.height,
);
}
if (a.type === "DRAW") {
const d = a.data as DrawData;
const threshold = toPx(d.strokeWidth, size.width) / 2 + 6;
return hitTestPath(d.paths, p, threshold, size);
}
if (a.type === "SHAPE") {
const d = a.data as ShapeData;
const threshold = toPx(d.strokeWidth, size.width) / 2 + 6;
return hitTestShape(d, p, threshold, size);
}
// ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน
// (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก
// และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน
const box = annotationBBox(a, { width: 100, height: 100 });
const padX = (4 / size.width) * 100;
const padY = (4 / size.height) * 100;
return (
p.x >= box.x - padX &&
p.x <= box.x + box.width + padX &&
p.y >= box.y - padY &&
p.y <= box.y + box.height + padY
);
}
/** กล่องเธรดคำตอบใน popover ของ comment pin (F9/A4) — state draft ของตัวเอง */
function ReplyBox({
annotationId,
replies,
ownUserId,
onCreateReply,
onDeleteReply,
}: {
annotationId: string;
replies: ReplyDTO[];
ownUserId: string;
onCreateReply: (annotationId: string, message: string) => void;
onDeleteReply: (id: string) => void;
}) {
const [draft, setDraft] = useState("");
function submit() {
const msg = draft.trim();
if (!msg) return;
setDraft("");
onCreateReply(annotationId, msg);
}
return (
<div className="mt-2 border-t border-slate-100 pt-2" onClick={(e) => e.stopPropagation()}>
{replies.length > 0 && (
<div className="max-h-36 space-y-1.5 overflow-y-auto">
{replies.map((r) => (
<div key={r.id} className="group flex items-start gap-1">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-slate-600">
{r.userName}
<span className="ml-1 font-normal text-slate-400" suppressHydrationWarning>
{new Date(r.createdAt).toLocaleString("th-TH", {
hour: "2-digit",
minute: "2-digit",
day: "2-digit",
month: "short",
})}
</span>
</p>
<p className="whitespace-pre-wrap break-words text-[11px]">{r.message}</p>
</div>
{r.userId === ownUserId && (
<button
type="button"
aria-label="ลบคำตอบ"
className="shrink-0 text-slate-300 hover:text-red-500"
onClick={(e) => {
e.stopPropagation();
onDeleteReply(r.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
<div className="mt-1.5 flex gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก
if (e.key === "Enter") submit();
}}
placeholder="ตอบ comment…"
className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<button
type="button"
disabled={!draft.trim()}
onClick={submit}
className="shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40"
>
ตอบ
</button>
</div>
</div>
);
}
// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ──────
function PdfPage({
pdfjs,
doc,
pageNumber,
width,
height,
tool,
shapeKind,
drawStyle,
shapeStyle,
textboxStyle,
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
pageBaseWidth,
liveMark,
liveMarkStyle,
ซ่อม 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>
2026-09-11 13:28:35 +07:00
selectPreview,
annotations,
replies,
openPinId,
ownUserId,
selectedId,
editingCommentId,
commentEditDraft,
onCommentEditDraft,
onStartCommentEdit,
textbox,
onSelectAnnotation,
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onSetResolved,
onAnnotationBookmark,
onCreateReply,
onDeleteReply,
onStrokeEnd,
onShapeEnd,
onRedactionEnd,
onErase,
}: {
pdfjs: typeof import("pdfjs-dist");
doc: import("pdfjs-dist").PDFDocumentProxy;
/** 1-based */
pageNumber: number;
width: number;
height: number;
tool: Tool;
shapeKind: ShapeKind;
/** ค่าปากกาปัจจุบันของ tool draw — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ */
drawStyle: { color: string; strokeWidthPct: number; opacity: number };
/** ค่าสี/ความหนาของ tool shape — ใช้ตอนสร้างรูปทรงใหม่ */
shapeStyle: { color: string; strokeWidthPct: number };
/** ค่าสี/ขนาดฟอนต์/หนา-ทึบ-จัดแนวของ tool textbox — ใช้ทั้ง composer และตอนบันทึก */
textboxStyle: {
color: string;
fontSizePct: number;
bold: boolean;
opacity: number;
align: "left" | "center" | "right";
/** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */
widthPct: number;
};
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
ซ่อม 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>
2026-09-11 13:28:35 +07:00
/** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
selectPreview: { pageIndex: number; rects: Rect[] } | null;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
pageBaseWidth: number;
annotations: AnnotationDTO[];
replies: ReplyDTO[];
openPinId: string | null;
ownUserId: string;
selectedId: string | null;
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
editingCommentId: string | null;
commentEditDraft: string;
onCommentEditDraft: (text: string) => void;
onStartCommentEdit: (id: string | null) => void;
/** F17/B2: state/handler ของกล่องข้อความ (composer จะ render เฉพาะหน้าที่ตรง pageIndex) */
textbox: TextboxUI;
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
onSelectAnnotation: (id: string) => void;
onPinToggle: (id: string | null) => void;
onAnnotationPreview: (data: AnnotationData | null) => void;
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
onSetResolved: (id: string, resolved: boolean) => void;
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
onCreateReply: (annotationId: string, message: string) => void;
onDeleteReply: (id: string) => void;
onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void;
onShapeEnd: (data: ShapeData) => void;
onRedactionEnd: (data: RedactionData) => void;
onErase: (id: string) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const textLayerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
let renderTask: import("pdfjs-dist").RenderTask | null = null;
(async () => {
const page = await doc.getPage(pageNumber);
if (cancelled) return;
const scale = width / page.getViewport({ scale: 1 }).width;
const viewport = page.getViewport({ scale });
// Canvas Retina: backing store ×dpr + transform แต่ style คง css px
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
renderTask = page.render({
canvasContext: ctx,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
});
await renderTask.promise.catch(() => undefined); // cancel ระหว่าง unmount ≠ error
if (cancelled) return;
// TextLayer — span ใช้ calc(var(--scale-factor)*…) → ต้องตั้งบน container
const container = textLayerRef.current;
if (!container) return;
const textContent = await page.getTextContent();
if (cancelled) return;
container.replaceChildren();
container.style.setProperty("--scale-factor", String(scale));
const textLayer = new pdfjs.TextLayer({
textContentSource: textContent,
container,
viewport,
});
await textLayer.render();
})().catch((err) => {
if (!cancelled) {
console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err);
}
});
return () => {
cancelled = true;
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
};
}, [pdfjs, doc, pageNumber, width, height]);
const highlights = annotations.filter(isHighlight);
const inkStrokes: InkStroke[] = annotations
.filter(isDraw)
.map((a) => ({
id: a.id,
userId: a.userId,
color: a.data.strokeColor,
strokeWidthPct: a.data.strokeWidth,
points: a.data.paths,
widthsPct: a.data.widths,
opacity: effectiveDrawOpacity(a.data),
}));
const inkShapes: InkShape[] = annotations
.filter(isShape)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const inkRedactions: InkRedaction[] = annotations
.filter(isRedaction)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const comments = annotations.filter(isComment);
const inkTextboxes: InkTextbox[] = annotations
.filter(isTextbox)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const inkStamps: InkStamp[] = annotations
.filter(isStamp)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
// tool ที่ InkLayer รับ pointer (วาดเส้น/รูปทรง/eraser/redact) → textLayer กับ pins ต้องปล่อยทาง
const inkActive =
tool === "draw" || tool === "shape" || tool === "erase" || tool === "redact";
// tool pan — ปิด pointer ของทุก overlay (textLayer/pins/textbox/selection) ให้ลากเลื่อนได้ทั้งหน้า
// แต่ InkLayer ต้องไม่ active ด้วย pan (ไม่งั้นการลากกลายเป็นวาดเส้น)
const overlayFrozen = inkActive || tool === "pan";
/** วาดมาร์ก 1 rect ตามรูปแบบ (ทึบ/เส้นใต้/ขีดฆ่า/ขยุกขยิก) — ใช้ทั้งของเดิมและ live preview
* opacity 0–1 (optional — ไม่มี = ทึบเต็ม) ใช้กับทุกรูปแบบ */
function renderMark(
key: string,
px: Rect,
color: string,
hStyle: HighlightStyle,
opacity?: number,
) {
const op = opacity ?? 1;
if (hStyle === "underline") {
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y + px.height - 1.5,
width: px.width,
height: 1.5,
backgroundColor: color,
opacity: op,
}}
/>
);
}
if (hStyle === "strikethrough") {
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y + px.height / 2 - 0.75,
width: px.width,
height: 1.5,
backgroundColor: color,
opacity: op,
}}
/>
);
}
if (hStyle === "squiggle") {
const w = Math.max(px.width, 4);
return (
<svg
key={key}
className="absolute"
style={{ left: px.x, top: px.y + px.height - 5, width: w, height: 5, opacity: op }}
viewBox={`0 0 ${w} 5`}
>
<path
d={squigglePath(w)}
stroke={color}
strokeWidth={1.4}
fill="none"
strokeLinecap="round"
/>
</svg>
);
}
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y,
width: px.width,
height: px.height,
backgroundColor: color,
opacity: op,
}}
/>
);
}
return (
<>
{/* z-1: canvas — pointer-events: none เสมอ */}
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
{/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order
แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */}
<div className="pointer-events-none absolute inset-0 z-[2] mix-blend-multiply">
{highlights.map((a) =>
a.data.rects.map((r, i) =>
renderMark(
`${a.id}-${i}`,
rectToPx(r, { width, height }),
a.data.color,
a.data.style ?? "highlight",
a.data.opacity,
),
),
)}
{/* live preview ของ tool highlight ระหว่างลากคลุมข้อความ */}
{liveMark &&
liveMark.pageIndex === pageNumber - 1 &&
liveMark.rects.map((r, i) =>
renderMark(
`live-${i}`,
rectToPx(r, { width, height }),
liveMarkStyle.color,
liveMarkStyle.style,
liveMarkStyle.opacity,
),
)}
ซ่อม 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>
2026-09-11 13:28:35 +07:00
{/* 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 */}
<div
ref={textLayerRef}
className="textLayer absolute inset-0 z-[2]"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
/>
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */}
<InkLayer
size={{ width, height }}
strokes={inkStrokes}
shapes={inkShapes}
redactions={inkRedactions}
active={inkActive}
nextStroke={{
color: drawStyle.color,
strokeWidthPct: drawStyle.strokeWidthPct,
opacity: drawStyle.opacity,
}}
shapeTool={
tool === "shape"
? { kind: shapeKind, color: shapeStyle.color, strokeWidthPct: shapeStyle.strokeWidthPct }
: null
}
onStrokeEnd={onStrokeEnd}
onShapeEnd={onShapeEnd}
onRedactionEnd={onRedactionEnd}
eraseMode={tool === "erase"}
redactMode={tool === "redact"}
ownUserId={ownUserId}
onErase={onErase}
/>
{/* z-4: TEXTBOX overlay (F17/B2) — ก่อน comment pins ใน DOM */}
<TextboxStampLayer
size={{ width, height }}
textboxes={inkTextboxes}
stamps={inkStamps}
enabled={!overlayFrozen}
ownUserId={ownUserId}
editingId={textbox.editingId}
onStartEdit={textbox.startEdit}
onCommitEdit={textbox.commitEdit}
composer={
textbox.composer && textbox.composer.pageIndex === pageNumber - 1
? {
position: textbox.composer.position,
fontSizePct: textboxStyle.fontSizePct,
color: textboxStyle.color,
bold: textboxStyle.bold,
opacity: textboxStyle.opacity,
align: textboxStyle.align,
widthPct: textboxStyle.widthPct,
}
: null
}
onCommitComposer={textbox.commitComposer}
onCancelComposer={textbox.cancelComposer}
onSelect={onSelectAnnotation}
/>
{/* z-4: comment pins — คอนเทนเนอร์โปร่งใส ปุ่ม pin รับ event เอง */}
<div className="pointer-events-none absolute inset-0 z-[4]">
{comments.map((a) => {
const x = toPx(a.data.position.x, width);
const y = toPx(a.data.position.y, height);
const open = openPinId === a.id;
return (
<div key={a.id} className="absolute" style={{ left: x, top: y }}>
<button
type="button"
aria-label="ดู comment"
className={`-translate-x-1/2 -translate-y-full rounded-full border p-1 shadow ${
a.resolved
? "border-emerald-300 bg-emerald-50 text-emerald-600 opacity-60 hover:opacity-100"
: "border-amber-300 bg-amber-100 text-amber-600 hover:bg-amber-200"
}`}
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
onClick={(e) => {
e.stopPropagation();
onPinToggle(open ? null : a.id);
}}
>
<MessageSquare className="h-4 w-4" />
</button>
{open && (
<div
className="absolute left-3 top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{a.userName}</p>
{/* F13/A6: ปุ่มแก้ไขข้อความ — เจ้าของเท่านั้น */}
{a.userId === ownUserId && editingCommentId !== a.id && (
<button
type="button"
aria-label="แก้ไขข้อความ"
title="แก้ไขข้อความ"
className="shrink-0 text-slate-400 hover:text-slate-600"
onClick={(e) => {
e.stopPropagation();
onStartCommentEdit(a.id);
}}
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
{editingCommentId === a.id ? (
<div className="mt-1">
<textarea
autoFocus
rows={3}
value={commentEditDraft}
onChange={(e) => onCommentEditDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของข้างนอก
if (e.key === "Escape") onStartCommentEdit(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
const msg = commentEditDraft.trim();
if (msg) {
onAnnotationUpdate(a.id, {
position: a.data.position,
message: msg,
});
onStartCommentEdit(null);
}
}
}}
className="w-full resize-none rounded border border-slate-300 p-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => onStartCommentEdit(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!commentEditDraft.trim()}
className="rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
onClick={() => {
const msg = commentEditDraft.trim();
if (!msg) return;
onAnnotationUpdate(a.id, {
position: a.data.position,
message: msg,
});
onStartCommentEdit(null);
}}
>
บันทึก
</button>
</div>
</div>
) : (
<p className="mt-1 whitespace-pre-wrap break-words">{a.data.message}</p>
)}
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
{new Date(a.createdAt).toLocaleString("th-TH")}
</p>
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
<button
type="button"
className={`mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${
a.resolved
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
: "border border-slate-300 text-slate-500 hover:bg-slate-100"
}`}
onClick={(e) => {
e.stopPropagation();
onSetResolved(a.id, !a.resolved);
}}
>
{a.resolved ? "✓ จัดการแล้ว (เลิกทำเครื่องหมาย)" : "ทำเครื่องหมายจัดการแล้ว"}
</button>
{/* F9: เธรดคำตอบ */}
<ReplyBox
annotationId={a.id}
replies={replies.filter((r) => r.annotationId === a.id)}
ownUserId={ownUserId}
onCreateReply={onCreateReply}
onDeleteReply={onDeleteReply}
/>
</div>
)}
</div>
);
})}
</div>
{/* z-4: กรอบ selection + handles ของรายการที่เลือก (F1) — หลัง pins ใน DOM */}
{(() => {
const selected = annotations.find((a) => a.id === selectedId);
if (!selected) return null;
return (
<AnnotationSelectionLayer
annotation={selected}
size={{ width, height }}
editable={ownUserId !== "" && selected.userId === ownUserId}
pageBaseWidth={pageBaseWidth}
disabled={overlayFrozen}
onPreview={onAnnotationPreview}
onCommit={onAnnotationUpdate}
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
/>
);
})()}
</>
);
}
// ─── Client root ─────────────────────────────────────────────────────────────
export default function PDFViewer({
file,
initialAnnotations,
initialBookmarks,
initialReplies,
}: {
file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[];
}) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
const [pageSizes, setPageSizes] = useState<Size[]>([]);
const [tool, setTool] = useState<Tool>("select");
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
const [drawPen, setDrawPen] = useState<DrawPen>("pencil"); // ชนิดปากกาของ tool draw (ปากกา/ไฮไลต์)
// ค่าปากกาปัจจุบัน — เลือก pen = รีเซ็ตเป็น preset ของ pen นั้น แล้วปรับแต่ต่อได้ (เมนูของปุ่ม DRAW)
const [drawColor, setDrawColor] = useState(DRAW_PENS.pencil.color);
const [drawWidthPct, setDrawWidthPct] = useState(DRAW_PENS.pencil.strokeWidthPct);
const [drawOpacityPct, setDrawOpacityPct] = useState(DRAW_PENS.pencil.opacity * 100);
// ค่าตั้งตอนสร้างของ shape/textbox/stamp — เมนูตั้งค่าบนปุ่ม tool แบบเดียวกับปากกาของ draw
const [shapeColor, setShapeColor] = useState("#ef4444");
const [shapeWidthPct, setShapeWidthPct] = useState(0.4); // % ของความกว้างหน้า
const [textboxColor, setTextboxColor] = useState(TEXTBOX_DEFAULT_COLOR);
const [textboxFontSizePct, setTextboxFontSizePct] = useState(TEXTBOX_DEFAULT_FONT_SIZE_PCT);
const [textboxBold, setTextboxBold] = useState(false);
const [textboxOpacityPct, setTextboxOpacityPct] = useState(100);
const [textboxAlign, setTextboxAlign] = useState<"left" | "center" | "right">("left");
const [stampColor, setStampColor] = useState(STAMP_COLOR);
const [stampScalePct, setStampScalePct] = useState(STAMP_DEFAULT_SCALE_PCT);
const [stampPreset, setStampPreset] = useState<string>(STAMP_PRESETS[0]); // F18: preset ตรายาง
const [stampMenuOpen, setStampMenuOpen] = useState(false); // F18: dropdown เลือก preset ตรายาง
const [shapeMenuOpen, setShapeMenuOpen] = useState(false); // F5: dropdown เลือกชนิดรูปทรง
const [drawMenuOpen, setDrawMenuOpen] = useState(false); // dropdown เลือกชนิดปากกา (draw)
const [textboxMenuOpen, setTextboxMenuOpen] = useState(false); // dropdown ตั้งค่ากล่องข้อความ
// tool highlight — คลุมข้อความแล้วสร้างทันทีตามรูปแบบ/สีที่ตั้งไว้ใน dropdown ของปุ่ม
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>("highlight");
/** ความทึบของมาร์กใหม่ 0–100 (เก็บเป็น % — เหมือน drawOpacityPct ของ tool draw) */
const [highlightOpacityPct, setHighlightOpacityPct] = useState(100);
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
const [highlightMenuOpen, setHighlightMenuOpen] = useState(false);
// UX review 2026-09-09 ข้อ 2: เมนู w-60 ของปุ่ม tool ที่อยู่ครึ่งขวาของจอ (หรือ wrap ลงแถวล่าง)
// อาจล้นขอบขวา — วัดตำแหน่งปุ่มตอนคลิก แล้วเลือก anchor left-0/right-0 ให้เมนูอยู่ในจอ
// (anchor ตายตัวใช้ไม่ได้: ปุ่มเดียวกันอยู่ชิดขวาบนจอกว้าง แต่ชิดซ้ายบนจอแคบที่ wrap)
// ค่าเขียนทับใหม่ทุกครั้งที่เปิดเมนู จึงไม่ต้องเคลียร์ตอนปิด
const [menuFlip, setMenuFlip] = useState<Record<string, boolean>>({});
const [scale, setScale] = useState(1);
const [user, setUser] = useState<LocalUser | null>(null);
const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// 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);
// F1 แก้ไข/ย้าย/ปรับขนาด — รายการที่เลือก + data ชั่วคราวระหว่างลาก (render ที่ layer เดิม)
const [selectedId, setSelectedId] = useState<string | null>(null);
// N1: annotation ที่ mouse ชี้อยู่บนหน้า — แสดง tooltip ใครทำ + โน้ต (เปลี่ยนค่าเฉพาะเมื่อรายการเปลี่ยน)
const [hoveredId, setHoveredId] = useState<string | null>(null);
// N2: popup บันทึก bookmark (ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ) — null = ปิด
const [bookmarkComposer, setBookmarkComposer] = useState<{
pageIndex: number;
yOffset: number;
title: string;
note: string;
parentId: string | null;
} | null>(null);
/** error ล่าสุดของการบันทึก bookmark — แสดงใน popup ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนเงียบ */
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
const bookmarkPendingRef = useRef(false);
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [commentEditDraft, setCommentEditDraft] = useState("");
// F17/B2: กล่องข้อความ — composer ตอนสร้าง + โหมดแก้ไขข้อความของตัวเอง (double-click)
const [textboxComposer, setTextboxComposer] = useState<TextboxComposer | null>(null);
const [editingTextboxId, setEditingTextboxId] = useState<string | null>(null);
// F4 ซ่อน–แสดงรายคน — "all" | "mine" | <userId> กรองฝั่ง client ล้วน (A3)
const [userFilter, setUserFilter] = useState<string>("all");
// UX review 2026-09-09: สลับ sidebar — จอ < xl เป็น overlay, desktop กดซ่อนเพื่อดู PDF เต็มจอ
// (เริ่ม true เพื่อให้ SSR เรนเดอร์แบบเปิดเหมือน desktop เดิม กัน hydration mismatch)
const [sidebarOpen, setSidebarOpen] = useState(true);
const {
annotations,
bookmarks,
replies,
setAnnotations,
setBookmarks,
setReplies,
} = useAnnotationRealtime(file.id, initialAnnotations, initialBookmarks, initialReplies);
// F2: undo/redo ต่อ session ต่อผู้ใช้ฝั่ง client (D10 — ไม่เก็บใน DB)
const {
push: pushHistory,
undo: undoHistory,
redo: redoHistory,
canUndo,
canRedo,
} = useHistory();
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
const [optimisticAnnotations, applyAnnotation] = useOptimistic(
annotations,
(state, action: AnnotationAction) => {
if (action.kind === "add") {
return state.some((a) => a.id === action.row.id)
? state
: [...state, action.row];
}
if (action.kind === "update") {
// replace ตาม id — idempotent เหมือน SSE update event (PLAN-FEAT.md §2)
return state.map((a) => (a.id === action.row.id ? action.row : a));
}
return state.filter((a) => a.id !== action.id);
},
);
// N2: การสร้าง bookmark รอผลจาก action ตรง ๆ (ไม่ optimistic) — เหลือแค่ remove
const [optimisticBookmarks, applyBookmark] = useOptimistic(
bookmarks,
(state, action: BookmarkAction) =>
action.kind === "remove" ? state.filter((b) => b.id !== action.id) : state,
);
// F4: รายชื่อผู้เขียน (unique ตาม userId จาก annotations — ตัวเองอยู่ใน option "ของฉัน" แล้ว)
const users = useMemo(() => {
const seen = new Map<string, string>();
for (const a of optimisticAnnotations) {
if (!seen.has(a.userId) && a.userId !== user?.userId) seen.set(a.userId, a.userName);
}
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
}, [optimisticAnnotations, user]);
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
const visibleAnnotations = useMemo(() => {
if (userFilter === "all") return optimisticAnnotations;
const target = userFilter === "mine" ? user?.userId : userFilter;
return optimisticAnnotations.filter((a) => a.userId === target);
}, [optimisticAnnotations, userFilter, user]);
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
// (O(points) ของเส้น DRAW) ครั้งเดียวต่อ list แทนคำนวณใหม่ทุก mousemove
const hoverBBoxPct = useMemo(() => {
const m = new Map<AnnotationDTO["id"], Rect>();
for (const a of visibleAnnotations) {
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
}
return m;
}, [visibleAnnotations]);
// F9: จำนวน reply ต่อ comment (สำหรับ sidebar)
const replyCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const r of replies) {
counts[r.annotationId] = (counts[r.annotationId] ?? 0) + 1;
}
return counts;
}, [replies]);
// F9: replies ของแม่ที่ยังแสดงอยู่ (ตาม filter ของ F4) — กรองครั้งเดียว ไม่ใช่ทุกหน้า
const visibleReplies = useMemo(
() =>
replies.filter((r) => visibleAnnotations.some((a) => a.id === r.annotationId)),
[replies, visibleAnnotations],
);
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
useEffect(() => {
setUser(getLocalUser());
}, []);
// UX review 2026-09-09: จอแคบกว่า xl (< 1280px) sidebar เป็น overlay — ปิดอัตโนมัติตอน mount
useEffect(() => {
if (!window.matchMedia("(min-width: 1280px)").matches) setSidebarOpen(false);
}, []);
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
useEffect(() => {
let cancelled = false;
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
(async () => {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// โหลดผ่าน /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();
return;
}
// ขนาดทุกหน้าที่ scale 1 ครั้งเดียว — ใช้ทำ placeholder + คำนวณ bookmark
const sizes: Size[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const vp = page.getViewport({ scale: 1 });
sizes.push({ width: vp.width, height: vp.height });
}
if (cancelled) {
void doc.destroy();
return;
}
setPdfjs(pdfjs);
setDoc(doc);
setPageSizes(sizes);
})().catch((err) => {
console.error("โหลดเอกสารไม่สำเร็จ", err);
});
return () => {
cancelled = true;
if (loadedDoc) void loadedDoc.destroy();
};
ซ่อม 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>
2026-09-11 13:28:35 +07:00
}, [file.id, file.pdfUrl]);
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
useEffect(() => {
const n = pageSizes.length;
if (n === 0) return;
const visible = new Set<number>();
const io = new IntersectionObserver(
(entries) => {
for (const en of entries) {
const p = Number((en.target as HTMLElement).dataset.page);
if (en.isIntersecting) visible.add(p);
else visible.delete(p);
}
if (visible.size === 0) return;
const next = new Set<number>();
for (const p of Array.from(visible)) {
for (const d of [-1, 0, 1]) {
const q = p + d;
if (q >= 0 && q < n) next.add(q);
}
}
setRenderPages(next);
},
{ rootMargin: "100% 0px" },
);
for (const el of pageWrapRefs.current) {
if (el) io.observe(el);
}
return () => io.disconnect();
}, [pageSizes]);
// หน้าปัจจุบัน = หน้าที่ midpoint ใกล้กึ่งกลาง viewport สุด (สำหรับ bookmark — PLAN.md §6.4)
useEffect(() => {
if (pageSizes.length === 0) return;
const onScroll = () => {
const center = window.scrollY + window.innerHeight / 2;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < pageWrapRefs.current.length; i++) {
const el = pageWrapRefs.current[i];
if (!el) continue;
const r = el.getBoundingClientRect();
const mid = r.top + window.scrollY + r.height / 2;
const d = Math.abs(mid - center);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
setCurrentPage(best);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [pageSizes]);
// F12/C4: deep link `?page=N` (1-based) และ/หรือ `?ann=<uuid>` — อ่าน
// window.location.search ครั้งเดียวหลัง pageSizes พร้อม (ไม่ใช้ useSearchParams
// เลี่ยงข้อจำกัด prerender ของ Next 14) — placeholder มีขนาดจริงทุกหน้าจึงคำนวณ
// absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark — §5)
const deepLinkDoneRef = useRef(false);
useEffect(() => {
if (pageSizes.length === 0 || deepLinkDoneRef.current) return;
deepLinkDoneRef.current = true;
const params = new URLSearchParams(window.location.search);
let targetPage: number | null = null;
const annId = params.get("ann");
if (annId) {
const a = annotations.find((x) => x.id === annId);
if (a) {
// กระโดด + เลือกด้วยกลไกเดียวกับคลิกใน sidebar (เลื่อนถึง bbox ใต้ header)
targetPage = a.pageIndex;
goToAnnotation(a);
}
}
const pageParam = Number(params.get("page"));
if (targetPage === null && Number.isInteger(pageParam) && pageParam >= 1) {
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
const el = pageWrapRefs.current[targetPage];
if (el) {
scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
}
}
}, [pageSizes, annotations]);
useEffect(() => {
setDraft("");
}, [composer]);
// F5/F18 + DRAW: dropdown บนปุ่ม tool (ชนิดปากกา/รูปทรง/ตรายาง) — ปิดเมื่อคลิกข้างนอก
// ส่วน Esc ปิดแค่เมนูก่อน (stopPropagation กันชนคีย์ลัด F3 ที่จะ deselect ทุกอย่างพร้อมกัน)
const stampMenuRef = useRef<HTMLDivElement | null>(null);
const shapeMenuRef = useRef<HTMLDivElement | null>(null);
const drawMenuRef = useRef<HTMLDivElement | null>(null);
const textboxMenuRef = useRef<HTMLDivElement | null>(null);
const highlightMenuRef = useRef<HTMLDivElement | null>(null);
// tool pan — ลากเลื่อนหน้า: เก็บตำแหน่ง pointer ล่าสุดระหว่างลาก (scrollBy สวนทางการเลื่อน)
const panRef = useRef<{ x: number; y: number } | null>(null);
const [grabbing, setGrabbing] = useState(false);
useEffect(() => {
const menus = [
{ open: stampMenuOpen, ref: stampMenuRef, close: () => setStampMenuOpen(false) },
{ open: shapeMenuOpen, ref: shapeMenuRef, close: () => setShapeMenuOpen(false) },
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
];
if (!menus.some((m) => m.open)) return;
const onMouseDown = (e: MouseEvent) => {
for (const m of menus) {
if (m.open && !m.ref.current?.contains(e.target as Node)) m.close();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
for (const m of menus) m.close();
}
};
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen]);
// 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)
ซ่อม 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>
2026-09-11 13:28:35 +07:00
const shortcutHandlersRef = useRef({
switchTool,
openBookmarkComposer,
undoHistory,
redoHistory,
deleteSelected: () => {},
});
useEffect(() => {
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
ซ่อม 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>
2026-09-11 13:28:35 +07:00
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) => {
const t = e.target as HTMLElement | null;
const inForm =
t !== null &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" || // รวม dropdown (เช่น "วางที่" ของ popup bookmark N2) — กดคีย์ลัดใน select ต้องข้าม
t.isContentEditable);
const h = shortcutHandlersRef.current;
if (e.key === "Escape") {
if (inForm) return; // ฟอร์ม (เช่น composer) จัดการ Esc เอง
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
setBookmarkComposer(null);
return;
}
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === "z" && !inForm) {
e.preventDefault();
if (e.shiftKey) void h.redoHistory();
else void h.undoHistory();
}
return; // Ctrl/Cmd + อื่น (เช่น Ctrl+C) ไม่ใช่คีย์ลัดของเรา
}
if (e.altKey || inForm) return;
ซ่อม 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>
2026-09-11 13:28:35 +07:00
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;
case "d":
h.switchTool("draw");
break;
case "c":
h.switchTool("comment");
break;
case "b":
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// ─── เขียนผ่าน Server Actions + optimistic (invariant #4, #9) ──────────────
// FIX-2: create ที่ยังรอ network อยู่ (key = temp id) —
// ลบทันทีก่อน action เสร็จ → mark cancelled แล้วฝั่ง create ลบ row จริงต่อเมื่อ
// action กลับมา (id ชั่วคราวยังไม่มีใน DB — ยิง delete ตอนนี้จะพัง แล้วรายการ
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
async function handleCreateAnnotation(
pageIndex: number,
type: AnnotationType,
data: AnnotationData,
) {
if (!user) return;
const now = new Date().toISOString();
const temp: AnnotationDTO = {
id: crypto.randomUUID(),
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
createdAt: now,
updatedAt: now,
resolved: false,
resolvedAt: null,
};
const pending = { cancelled: false };
pendingCreatesRef.current.set(temp.id, pending);
await startTransition(async () => {
applyAnnotation({ kind: "add", row: temp });
try {
const saved = await createAnnotation({
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
if (pending.cancelled) {
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
await deleteAnnotation(saved.id, user.userId);
return;
}
setAnnotations((prev) =>
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved],
);
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
let currentId = saved.id;
pushHistory({
undo: async () => {
await deleteAnnotation(currentId, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
redo: async () => {
const re = await createAnnotation({
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
currentId = re.id;
setAnnotations((prev) =>
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
);
},
});
} catch (err) {
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
} finally {
pendingCreatesRef.current.delete(temp.id);
}
});
}
async function handleDeleteAnnotation(id: string) {
if (!user) return;
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
// optimistic row จะหายเองเมื่อ transition ของ create จบลง
const pending = pendingCreatesRef.current.get(id);
if (pending) {
pending.cancelled = true;
setSelectedId(null);
setPreviewData(null);
return;
}
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
setSelectedId(null);
setPreviewData(null);
await startTransition(async () => {
applyAnnotation({ kind: "remove", id });
try {
await deleteAnnotation(id, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== id));
if (target) {
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
let currentId: string | null = null;
pushHistory({
undo: async () => {
const re = await createAnnotation({
fileId: file.id,
pageIndex: target.pageIndex,
userId: target.userId,
userName: target.userName,
type: target.type,
data: target.data,
});
currentId = re.id;
setAnnotations((prev) =>
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
);
},
redo: async () => {
if (!currentId) return;
await deleteAnnotation(currentId, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
});
}
} catch (err) {
console.error("ลบ annotation ไม่สำเร็จ", err);
}
});
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
if (!user) return;
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
setPreviewData(null);
await startTransition(async () => {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
try {
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
}
});
}
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
// เพราะ undo ผ่าน updateAnnotation จำกัดเจ้าของ และสเปก F2 ก็ไม่รวม resolve) ──
async function handleSetResolved(id: string, resolved: boolean) {
const current = annotations.find((a) => a.id === id);
if (!current) return;
const now = new Date().toISOString();
await startTransition(async () => {
applyAnnotation({
kind: "update",
row: {
...current,
resolved,
resolvedAt: resolved ? now : null,
updatedAt: now,
},
});
try {
const saved = await setAnnotationResolved(id, resolved);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) {
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
}
});
}
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
async function handleCreateReply(annotationId: string, message: string) {
if (!user) return;
try {
const saved = await createReply({
annotationId,
userId: user.userId,
userName: user.userName,
message,
});
setReplies((prev) =>
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
);
} catch (err) {
console.error("ตอบ comment ไม่สำเร็จ", err);
}
}
async function handleDeleteReply(id: string) {
if (!user) return;
try {
await deleteReply(id, user.userId);
setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
console.error("ลบคำตอบไม่สำเร็จ", err);
}
}
// ─── Bookmarks (N2: หมุดหัวข้อใหญ่ + sub-bookmark + โน้ต) ────────────────────
/** ข้อความที่เพิ่งคลุมบนหน้าล่าสุด — ชื่อ default ของ popup bookmark (จาก mouseup บนหน้า) */
const lastSelectionTextRef = useRef("");
ซ่อม 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>
2026-09-11 13:28:35 +07:00
/** เส้นทางลากเมาส์ของ 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 {
let depth = 1;
let cur: BookmarkDTO | undefined = b;
while (cur?.parentId && depth <= 100) {
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId);
if (!parent) break;
depth += 1;
cur = parent;
}
return depth;
}
function openBookmarkComposer() {
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
setBookmarkComposer({ ...pos, title: lastSelectionTextRef.current, note: "", parentId: null });
}
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้นเว้นว่าง = "หน้า N" */
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
const meaningful =
a.type === "HIGHLIGHT" ||
a.type === "TEXTBOX" ||
a.type === "STAMP" ||
a.type === "COMMENT";
setBookmarkError(null);
setBookmarkComposer({
pageIndex: a.pageIndex,
yOffset: clamp(yOffsetPct, 0, 100),
title: meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "",
note: "",
parentId: null,
});
}
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
function currentViewPosition() {
const el = pageWrapRefs.current[currentPage];
if (!el) return null;
const r = el.getBoundingClientRect();
const centerY = window.scrollY + window.innerHeight / 2;
const pageTop = r.top + window.scrollY;
return {
pageIndex: currentPage,
yOffset: clamp(((centerY - pageTop) / r.height) * 100, 0, 100),
};
}
async function handleSaveBookmark() {
const c = bookmarkComposer;
if (!c || !user || bookmarkPendingRef.current) return;
bookmarkPendingRef.current = true;
try {
const saved = await createBookmark({
fileId: file.id,
pageIndex: c.pageIndex,
userId: user.userId,
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
yOffset: c.yOffset,
parentId: c.parentId,
note: c.note.trim() || null,
});
// ปิด popup เฉพาะเมื่อสำเร็จ — ถ้า fail (เช่น หัวข้อแม่ถูกคนอื่นลบไประหว่างกรอก)
// คงข้อมูลที่พิมพ์ไว้ + แสดง error ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนหายเงียบ ๆ
setBookmarkComposer(null);
setBookmarkError(null);
setBookmarks((prev) =>
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
} catch (err) {
console.error("บันทึก bookmark ไม่สำเร็จ", err);
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
} finally {
bookmarkPendingRef.current = false;
}
}
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
async function handleAddSubBookmark(parentId: string) {
if (!user || bookmarkPendingRef.current) return;
const pos = currentViewPosition();
if (!pos) return;
bookmarkPendingRef.current = true;
try {
const saved = await createBookmark({
fileId: file.id,
pageIndex: pos.pageIndex,
userId: user.userId,
title: `หน้า ${pos.pageIndex + 1}`,
yOffset: pos.yOffset,
parentId,
});
setBookmarks((prev) =>
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
} catch (err) {
console.error("เพิ่ม sub-bookmark ไม่สำเร็จ", err);
} finally {
bookmarkPendingRef.current = false;
}
}
/** แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (✏ ใน sidebar, N2) */
async function handleUpdateBookmark(
id: string,
patch: { title?: string; note?: string | null; parentId?: string | null },
) {
if (!user) return;
try {
const saved = await updateBookmark({ id, userId: user.userId, ...patch });
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
} catch (err) {
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
}
}
async function handleDeleteBookmark(id: string) {
if (!user) return;
await startTransition(async () => {
applyBookmark({ kind: "remove", id });
try {
await deleteBookmark(id, user.userId);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
console.error("ลบ bookmark ไม่สำเร็จ", err);
}
});
}
// ─── Text selection → highlight (PLAN.md §6.1) ─────────────────────────────
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
ซ่อม 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>
2026-09-11 13:28:35 +07:00
* ใช้ทั้งตอน 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;
}
const range = selection.getRangeAt(0);
// รับเฉพาะ selection ใน page เดียว (PLAN.md §8)
const container = range.commonAncestorContainer;
const node =
container instanceof Element ? container : container.parentElement;
const pageEl = node?.closest<HTMLElement>("[data-page]");
if (!pageEl || Number(pageEl.dataset.page) !== pageIndex) return null;
const clientRects = Array.from(range.getClientRects()).filter(
(r) => r.width > 0,
);
if (clientRects.length === 0) return null;
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// จำกัดเฉพาะแนวเส้นทางที่ลากเมาส์จริง — 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();
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// จำเส้นทางลาก (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,
y: r.top - pageRect.top,
width: r.width,
height: r.height,
},
{ width: pageRect.width, height: pageRect.height },
),
);
return {
rects: mergeRectsByLine(rectsPct), // รวบเหลือ 1 rect ต่อบรรทัดก่อนบันทึก
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// ข้อความกรองให้เหลือเฉพาะตัวอักษรใน 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 ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
useEffect(() => {
if (!TEXT_SELECTION_TOOLS.has(tool)) {
setLiveMark(null);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
setSelectPreview(null);
return;
}
const onSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setLiveMark(null);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
setSelectPreview(null);
return;
}
const node = selection.getRangeAt(0).commonAncestorContainer;
const el = node instanceof Element ? node : node.parentElement;
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) {
setLiveMark(null);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
setSelectPreview(null);
return;
}
const pi = Number(pageEl.dataset.page);
const sel = readTextSelection(pi);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
const setPreview = tool === "highlight" ? setLiveMark : setSelectPreview;
setPreview((prev) => {
const next = sel ? { pageIndex: pi, rects: sel.rects } : null;
// ค่าเดิมทุกอย่าง = คืน object เดิมให้ React ข้าม re-render (ลากทีละนิดไม่หนัก)
const same =
prev !== null &&
next !== null &&
prev.pageIndex === next.pageIndex &&
prev.rects.length === next.rects.length &&
prev.rects.every(
(r, idx) =>
r.x === next.rects[idx].x &&
r.y === next.rects[idx].y &&
r.width === next.rects[idx].width &&
r.height === next.rects[idx].height,
);
return same ? prev : next;
});
};
document.addEventListener("selectionchange", onSelectionChange);
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — 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 (!TEXT_SELECTION_TOOLS.has(tool)) return;
ซ่อม 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>
2026-09-11 13:28:35 +07:00
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)
ซ่อม 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>
2026-09-11 13:28:35 +07:00
// อ่านครั้งเดียวพร้อมข้อความ (กรองตาม rects แล้ว) — bookmark กับมาร์กใช้ชุดเดียวกัน
const sel = readTextSelection(pageIndex, { withText: true });
lastSelectionTextRef.current = normalizeBookmarkTitle(sel?.text ?? "");
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
if (tool !== "highlight") return;
if (!sel) return;
setLiveMark(null);
window.getSelection()?.removeAllRanges();
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
text: sel.text,
color: highlightColor,
rects: sel.rects,
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
});
}
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
/** N1: ชี้ mouse เหนือ annotation บนหน้า → เก็บ id ไว้แสดง tooltip (ใครทำ + โน้ต)
* hit-test ชุดเดียวกับการคลิกเลือก (ตัวบนสุดชนะ) — เปลี่ยน state เฉพาะเมื่อ id เปลี่ยน
* จึงไม่ re-render ทุก mousemove; ปิดช่วง tool ที่รับ pointer (วาด/ลบ), กดปุ่มค้าง
* หรือตอน popover ของ comment pin เปิดอยู่ (กัน tooltip ลอยทับ popover) */
function handlePageHoverMove(
e: ReactMouseEvent<HTMLDivElement>,
pageIndex: number,
) {
const frozen =
tool === "draw" ||
tool === "shape" ||
tool === "erase" ||
tool === "redact" ||
tool === "pan";
if (frozen || e.buttons !== 0 || openPinId !== null) {
if (hoveredId !== null) setHoveredId(null);
return;
}
const base = pageSizes[pageIndex];
if (!base) return;
const size = { width: base.width * scale, height: base.height * scale };
const p = getPointerPercent(e, e.currentTarget);
// เดินจากรายการหลังสุดก่อน (ตัวบนสุดชนะ) — for-loop ตรง ๆ ไม่สร้าง array ใหม่ต่อ mousemove
let id: string | null = null;
for (let k = visibleAnnotations.length - 1; k >= 0; k--) {
const a = visibleAnnotations[k];
if (a.pageIndex !== pageIndex || a.type === "COMMENT") continue; // pin มี popover ของตัวเองอยู่แล้ว
// FIX-8: bbox pre-filter ก่อน hit-test แม่น — hitTestPath ของเส้น DRAW เป็น
// O(points) ต่อเส้น ตัดรายการที่จุดอยู่ไกลทิ้งก่อนด้วย bbox + margin เผื่อ threshold
// ของ hit-test (generic +4px, DRAW/SHAPE +6px — bbox ของเส้นขยายความหนาครึ่งหนึ่งแล้ว)
const box = hoverBBoxPct.get(a.id);
if (box) {
const marginPx = 6;
const mx = (marginPx / size.width) * 100;
const my = (marginPx / size.height) * 100;
if (
p.x < box.x - mx ||
p.x > box.x + box.width + mx ||
p.y < box.y - my ||
p.y > box.y + box.height + my
) {
continue;
}
}
if (hitTestAnnotation(a, p, size)) {
id = a.id;
break;
}
}
setHoveredId((prev) => (prev === id ? prev : id));
}
function handlePageClick(
e: ReactMouseEvent<HTMLDivElement>,
pageIndex: number,
pageEl: HTMLElement,
) {
// ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก —
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือกข้อความ ไม่เปิด
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
// stopPropagation ที่ layer ของมันแล้ว ส่วน tool select/highlight ตั้ง selectedId
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
if (selectedId !== null) {
setSelectedId(null);
setPreviewData(null);
}
if (tool === "comment" && user) {
const position = getPointerPercent(e, pageEl);
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
return;
}
// F17/B2: คลิกบนหน้า = เปิด textarea สร้างกล่องข้อความ ณ ตำแหน่งนั้น
if (tool === "textbox" && user) {
const position = getPointerPercent(e, pageEl);
setTextboxComposer({ pageIndex, position });
return;
}
// F18/B5: คลิกบนหน้า = วางตรายาง preset ที่เลือกไว้ทันที (สี/ขนาดจากเมนูตั้งค่าของ tool)
if (tool === "stamp" && user) {
const position = getPointerPercent(e, pageEl);
void handleCreateAnnotation(pageIndex, "STAMP", {
label: stampPreset,
position,
scale: stampScalePct,
color: stampColor,
});
return;
}
// tool select/highlight — คลิกบน annotation ตัวไหน = เลือกตัวนั้น (กรอบ F1 + ปุ่มลบ)
// เช็คจากรายการหลังสุดก่อนเพื่อให้ตัวที่วาดทีหลัง (อยู่บน) ถูกเลือกก่อน
if (tool === "select" || tool === "highlight") {
const base = pageSizes[pageIndex];
if (base) {
const size = { width: base.width * scale, height: base.height * scale };
const p = getPointerPercent(e, pageEl);
const hit = [...visibleAnnotations]
.reverse()
.find((a) => a.pageIndex === pageIndex && hitTestAnnotation(a, p, size));
if (hit) {
setSelectedId(hit.id);
return;
}
}
}
}
async function handleComposerSave() {
const c = composer;
if (!c || !draft.trim() || !user) return;
setComposer(null);
await handleCreateAnnotation(c.pageIndex, "COMMENT", {
position: c.position,
message: draft.trim(),
});
}
// ─── Scroll-to จาก sidebar (PLAN.md §6.4 — คำนวณ absolute ครั้งเดียว) ──────
function goToBookmark(b: BookmarkDTO) {
const el = pageWrapRefs.current[b.pageIndex];
if (!el) return;
const r = el.getBoundingClientRect();
const pageTop = r.top + window.scrollY;
// คืนมุมมองตอนบันทึก: จุด yOffset (ซึ่งเก็บจากกึ่งกลางจอตอนสร้าง) กลับไปอยู่
// กึ่งกลางพื้นที่มองเห็นใต้ header — เดิมวางจุดนี้ไว้ที่ขอบบน viewport เลยเลื่อน
// เกินลงล่างครึ่งจอ (bookmark หน้าแรกจึงดูเหมือน "ไม่ขึ้นบนสุด")
const center = (viewerHeaderHeight() + window.innerHeight) / 2;
window.scrollTo({
top: Math.max(0, pageTop + (r.height * b.yOffset) / 100 - center),
behavior: "smooth",
});
}
function goToAnnotation(a: AnnotationDTO) {
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
setSelectedId(a.id);
setPreviewData(null);
const el = pageWrapRefs.current[a.pageIndex];
if (!el) return;
const r = el.getBoundingClientRect();
const boxPct = annotationBBox(a, { width: 100, height: 100 });
scrollToBelowHeader(r.top + window.scrollY + (r.height * boxPct.y) / 100);
}
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
function handlePinToggle(id: string | null) {
setOpenPinId(id);
if (id) setSelectedId(id);
}
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
function handleSelectAnnotation(id: string) {
setSelectedId(id);
setPreviewData(null);
}
/** F13/A6: เข้าสู่โหมดแก้ไขข้อความ comment (null = ยกเลิก) — draft เริ่มจากข้อความปัจจุบัน */
function startCommentEdit(id: string | null) {
setEditingCommentId(id);
if (id) {
const a = annotations.find((x) => x.id === id && x.type === "COMMENT");
setCommentEditDraft(a ? (a.data as CommentData).message : "");
}
}
// ─── Text box (F17/B2) ─────────────────────────────────────────────────────
/** เข้าสู่โหมดแก้ไขข้อความ TEXTBOX ของตัวเอง (double-click — null = ยกเลิก) */
function startTextboxEdit(id: string | null) {
setEditingTextboxId(id);
}
/** บันทึกข้อความที่แก้ — reuse updateAnnotation ของ F1 (เจ้าของเท่านั้น การกันอยู่ที่ handler)
* segments (ตัวหนาเฉพาะส่วน) เก็บเฉพาะเมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
const current = annotations.find((a) => a.id === id);
if (!current || current.type !== "TEXTBOX") return;
setEditingTextboxId(null);
const hasBold = !!segments?.some((s) => s.bold);
void handleUpdateAnnotation(id, {
...current.data,
text,
segments: hasBold ? segments : undefined,
});
}
/** บันทึกกล่องข้อความใหม่จาก composer ณ ตำแหน่งที่คลิก (ค่าทั้งหมดจากเมนูตั้งค่าของ tool) */
function commitTextboxComposer(text: string, segments?: TextboxSegment[]) {
const c = textboxComposer;
if (!c) return;
setTextboxComposer(null);
const hasBold = !!segments?.some((s) => s.bold);
void handleCreateAnnotation(c.pageIndex, "TEXTBOX", {
position: c.position,
text,
color: textboxColor,
fontSize: textboxFontSizePct,
...(textboxBold ? { bold: true } : {}),
opacity: textboxOpacityPct / 100,
align: textboxAlign,
// กล่องกว้างคงที่ (สไตล์ Smallpdf) — บีบด้วยพื้นที่จริงถึงขอบขวาตอนสร้าง
// ให้ตรงกับที่ editor แสดง (maxWidth ของ CSS clamp ให้อยู่แล้วระหว่างพิมพ์)
width: Math.min(TEXTBOX_DEFAULT_WIDTH_PCT, textboxMaxWidthPct(c.position.x)),
...(hasBold ? { segments } : {}),
});
}
function switchTool(t: Tool) {
setTool(t);
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
window.getSelection()?.removeAllRanges();
}
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
function goToPage(pageIndex: number) {
const el = pageWrapRefs.current[pageIndex];
if (!el) return;
const r = el.getBoundingClientRect();
scrollToBelowHeader(r.top + window.scrollY);
}
// ─── Render ────────────────────────────────────────────────────────────────
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
const drawStyle = {
color: drawColor,
strokeWidthPct: drawWidthPct,
opacity: drawOpacityPct / 100,
};
// ค่าตั้งตอนสร้างของ shape/textbox — ส่งลง PdfPage (stamp ใช้ตรงจุดสร้างใน viewer เอง)
const shapeStyle = { color: shapeColor, strokeWidthPct: shapeWidthPct };
const textboxStyle = {
color: textboxColor,
fontSizePct: textboxFontSizePct,
bold: textboxBold,
opacity: textboxOpacityPct / 100,
align: textboxAlign,
widthPct: TEXTBOX_DEFAULT_WIDTH_PCT,
};
// ความกว้างหน้าแรกที่ scale 1 (≈ pt ของ PDF) — ใช้แปลงหน่วย fontSize ระหว่าง % (ที่เก็บใน DB)
// กับ px ที่แสดงใน UI (fallback 595 = ความกว้าง A4 ก่อนเอกสารโหลด)
const basePageWidth = pageSizes[0]?.width ?? 595;
return (
<div className="flex min-h-screen">
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
สะอาดกว่า native) */}
{TEXT_SELECTION_TOOLS.has(tool) && (
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
)}
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-[45] bg-slate-900/30 xl:hidden"
aria-hidden
onClick={() => setSidebarOpen(false)}
/>
)}
{/* xl+ = sticky แยกคอลัมน์เหมือนเดิม / < xl = overlay เลื่อนเข้า-ออก
(ปิดบน desktop ใช้ xl:hidden ออกจาก flow → main เต็มจอ) */}
<aside
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-xl:fixed max-xl:left-0 max-xl:z-50 max-xl:transition-transform max-xl:duration-200 ${
sidebarOpen
? "max-xl:translate-x-0"
: "max-xl:pointer-events-none max-xl:-translate-x-full xl:hidden"
}`}
>
<AnnotationSidebar
annotations={visibleAnnotations}
bookmarks={optimisticBookmarks}
fileId={file.id}
userId={user?.userId ?? ""}
userFilter={userFilter}
users={users}
onUserFilterChange={setUserFilter}
replyCounts={replyCounts}
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
onUpdateAnnotation={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
onGoToBookmark={goToBookmark}
onGoToAnnotation={goToAnnotation}
/>
</aside>
<div className="min-w-0 flex-1">
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
<div className="touch-44 flex flex-wrap items-center gap-2 px-4 py-2">
{/* UX review 2026-09-09: สลับ sidebar (ทุกขนาดจอ — desktop ซ่อนเพื่อดู PDF เต็มจอได้) */}
<button
type="button"
title="สลับแผงข้าง"
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
aria-expanded={sidebarOpen}
className="shrink-0 rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setSidebarOpen((o) => !o)}
>
<PanelLeft className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold">
{file.title}
</h1>
{/* ตัวเลื่อนหน้า < หน้า 1/12 > + ซูม −/%/+ — อยู่ด้วยกันกึ่งกลางแถว title (ซ้าย-ขวา flex-1 เท่ากัน) */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center rounded-lg border border-slate-300">
<button
type="button"
aria-label="หน้าก่อนหน้า"
disabled={currentPage <= 0}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
onClick={() => goToPage(currentPage - 1)}
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="w-16 text-center text-xs tabular-nums">
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
</span>
<button
type="button"
aria-label="หน้าถัดไป"
disabled={currentPage >= pageSizes.length - 1}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
onClick={() => goToPage(currentPage + 1)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center rounded-lg border border-slate-300">
<button
type="button"
aria-label="ซูมออก"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="w-12 text-center text-xs tabular-nums">
{Math.round(scale * 100)}%
</span>
<button
type="button"
aria-label="ซูมเข้า"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))}
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ */}
<Link
href={`/annotate/${file.id}/summary`}
title="หน้าสรุปหมายเหตุ"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
>
<ClipboardList className="h-4 w-4" />
</Link>
</div>
</div>
<div className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2">
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */}
<div className="flex flex-wrap rounded-lg border border-slate-300">
{TOOLS.map(({ key, label, Icon }, i) => {
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
const activeCls = tool === key
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100";
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
if (key === "highlight") {
const CurrentIcon =
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon;
return (
<div key={key} ref={highlightMenuRef} className="relative">
<button
type="button"
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={highlightMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
else {
switchTool("highlight");
setHighlightMenuOpen(true);
}
}}
>
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{highlightMenuOpen && (
<div
aria-label="ตั้งค่าการมาร์กข้อความ"
className={`absolute ${menuFlip.highlight ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => (
<button
key={sKey}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
highlightStyle === sKey
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setHighlightStyle(sKey)}
>
<SIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{sLabel}</span>
{highlightStyle === sKey && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-slate-100" />
<PanelRow label="สี">
<ColorSwatches value={highlightColor} onChange={setHighlightColor} />
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={highlightOpacityPct}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setHighlightOpacityPct}
/>
</PanelRow>
</div>
)}
</div>
);
}
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
if (key === "draw") {
const CurrentPenIcon = DRAW_PEN_ICONS[drawPen];
return (
<div key={key} ref={drawMenuRef} className="relative">
<button
type="button"
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={drawMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
if (tool === "draw") setDrawMenuOpen((o) => !o);
else {
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setDrawMenuOpen(true);
}
}}
>
<CurrentPenIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{drawMenuOpen && (
<div
aria-label="ตั้งค่าปากกา"
className={`absolute ${menuFlip.draw ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => {
const PenIcon = DRAW_PEN_ICONS[p];
return (
<button
key={p}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
drawPen === p
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => {
setDrawPen(p);
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น (เมนูเปิดค้างให้ปรับต่อ)
setDrawColor(DRAW_PENS[p].color);
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
}}
>
<PenIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{DRAW_PENS[p].label}</span>
{drawPen === p && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
);
})}
<div className="my-1.5 border-t border-slate-100" />
<PanelRow label="สี">
<ColorSwatches value={drawColor} onChange={setDrawColor} />
</PanelRow>
<PanelRow label="ความหนา">
{/* แสดงเป็นหน่วยย่อ ×10 ของ % ความกว้างหน้า (4 = ปากกาเดิม 0.4%, 12 = ไฮไลต์เดิม 1.2%)
— toFixed กัน float artifact ของการแปลง ÷10/×10 รอบกลับ */}
<NumberCombo
value={Number((drawWidthPct * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
step={1}
onChange={(v) => setDrawWidthPct(v / 10)}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round(drawOpacityPct)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setDrawOpacityPct}
/>
</PanelRow>
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
</p>
</div>
)}
</div>
);
}
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
if (key === "shape") {
return (
<div key={key} ref={shapeMenuRef} className="relative">
<button
type="button"
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={shapeMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, shape: r.left + 248 > window.innerWidth }));
if (tool === "shape") setShapeMenuOpen((o) => !o);
else {
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setShapeMenuOpen(true);
}
}}
>
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{shapeMenuOpen && (
<div
aria-label="ตั้งค่ารูปทรง"
className={`absolute ${menuFlip.shape ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{SHAPE_KIND_BTNS.map(({ key: kind, label: kindLabel, Icon: KindIcon }) => (
<button
key={kind}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
shapeKind === kind
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setShapeKind(kind)}
>
<KindIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{kindLabel}</span>
{shapeKind === kind && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-slate-100" />
<PanelRow label="สี">
<ColorSwatches value={shapeColor} onChange={setShapeColor} />
</PanelRow>
<PanelRow label="ความหนา">
{/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับความหนาของปากกา */}
<NumberCombo
value={Number((shapeWidthPct * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
step={1}
onChange={(v) => setShapeWidthPct(v / 10)}
/>
</PanelRow>
</div>
)}
</div>
);
}
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset
if (key === "stamp") {
return (
<div key={key} ref={stampMenuRef} className="relative">
<button
type="button"
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={stampMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${corner} ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, stamp: r.left + 248 > window.innerWidth }));
if (tool === "stamp") setStampMenuOpen((o) => !o);
else {
switchTool("stamp");
setStampMenuOpen(true);
}
}}
>
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{stampMenuOpen && (
<div
aria-label="ตั้งค่าตรายาง"
className={`absolute ${menuFlip.stamp ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
stampPreset === preset
? "bg-red-50 text-red-700"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setStampPreset(preset)}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-slate-100" />
<PanelRow label="สี">
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
</div>
)}
</div>
);
}
// F17/B2: ปุ่มกล่องข้อความ = dropdown ตั้งค่าสี/ขนาดฟอนต์
if (key === "textbox") {
return (
<div key={key} ref={textboxMenuRef} className="relative">
<button
type="button"
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={textboxMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, textbox: r.left + 248 > window.innerWidth }));
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
else {
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setTextboxMenuOpen(true);
}
}}
>
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{textboxMenuOpen && (
<div
aria-label="ตั้งค่ากล่องข้อความ"
className={`absolute ${menuFlip.textbox ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
<PanelRow label="สี">
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง (scale 1 ≈ pt) — เก็บใน DB เป็น %
ของความกว้างหน้าเหมือนเดิม (zoom ไม่ทำให้ตัวเลขเปลี่ยน) */}
<NumberCombo
value={Number(((textboxFontSizePct / 100) * basePageWidth).toFixed(1))}
options={[10, 12, 14, 18, 24]}
min={6}
max={48}
step={1}
suffix="px"
onChange={(v) => setTextboxFontSizePct((v / basePageWidth) * 100)}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round(textboxOpacityPct)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setTextboxOpacityPct}
/>
</PanelRow>
<PanelRow label="ตัวหนา">
<button
type="button"
aria-label="สลับตัวหนา"
aria-pressed={textboxBold}
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${
textboxBold
? "border-primary bg-primary text-white"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
}`}
onClick={() => setTextboxBold((o) => !o)}
>
{/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */}
<span aria-hidden className="text-[13px] font-black leading-none">
B
</span>
</button>
</PanelRow>
<PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-slate-300">
{(
[
["left", AlignLeft, "ชิดซ้าย"],
["center", AlignCenter, "กึ่งกลาง"],
["right", AlignRight, "ชิดขวา"],
] as const
).map(([a, AlignIcon, alignLabel]) => (
<button
key={a}
type="button"
aria-label={`จัดแนว${alignLabel}`}
aria-pressed={textboxAlign === a}
className={`px-2 py-1 transition ${
textboxAlign === a
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => setTextboxAlign(a)}
>
<AlignIcon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
</div>
)}
</div>
);
}
return (
<button
key={key}
type="button"
title={label}
aria-label={label}
className={`px-2 py-1.5 transition ${corner} ${activeCls}`}
onClick={() => switchTool(key)}
>
<Icon className="h-4 w-4" />
</button>
);
})}
</div>
{/* F8: hint ความปลอดภัยของ redaction (PLAN-FEAT.md §5 — บนจอบังเท่านั้น) */}
{tool === "redact" && (
<p className="hidden max-w-xs text-[11px] leading-tight text-amber-600 lg:block">
เป็นการปกปิดข้อมูลบนหน้าจอชั่วคราวเท่านั้น (ข้อความดั้งเดิมยังสามารถคัดลอกได้)
กรุณากด 'Export PDF' เพื่อปกปิดข้อมูลอย่างถาวรก่อนนำไปเผยแพร่
</p>
)}
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack */}
<div className="flex items-center rounded-lg border border-slate-300">
<button
type="button"
title="Undo (Ctrl+Z)"
aria-label="ย้อนการกระทำล่าสุด"
disabled={!canUndo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
onClick={() => void undoHistory()}
>
<Undo2 className="h-4 w-4" />
</button>
<button
type="button"
title="Redo (Ctrl+Shift+Z)"
aria-label="ทำซ้ำการกระทำ"
disabled={!canRedo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
onClick={() => void redoHistory()}
>
<Redo2 className="h-4 w-4" />
</button>
</div>
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
<div className="relative ml-auto">
<button
type="button"
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`}
disabled={!user}
onClick={openBookmarkComposer}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40"
>
<BookmarkPlus className="h-4 w-4" />
</button>
{bookmarkComposer && (
<div
className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<p className="text-[11px] font-semibold text-slate-700">
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
</p>
<input
autoFocus
value={bookmarkComposer.title}
onChange={(e) =>
setBookmarkComposer({ ...bookmarkComposer, title: e.target.value })
}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัดของ viewer
if (e.key === "Enter") void handleSaveBookmark();
if (e.key === "Escape") setBookmarkComposer(null);
}}
placeholder={`ชื่อหัวข้อ (เว้นว่าง = "หน้า ${bookmarkComposer.pageIndex + 1}")`}
className="mt-2 w-full rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<textarea
rows={2}
value={bookmarkComposer.note}
onChange={(e) =>
setBookmarkComposer({ ...bookmarkComposer, note: e.target.value })
}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") setBookmarkComposer(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
void handleSaveBookmark();
}
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="mt-1.5 text-[11px] text-slate-400">วางที่</p>
<Select
value={bookmarkComposer.parentId ?? ""}
onChange={(e) =>
setBookmarkComposer({
...bookmarkComposer,
parentId: e.target.value || null,
})
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{optimisticBookmarks
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
.filter((b) => bookmarkDepthOf(b) < 3)
.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot;
</option>
))}
</Select>
{bookmarkError && (
<p className="mt-1.5 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
{bookmarkError}
</p>
)}
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100"
onClick={() => setBookmarkComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85"
onClick={() => void handleSaveBookmark()}
>
บันทึก
</button>
</div>
</div>
)}
</div>
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
<button
type="button"
title="Export PDF พร้อม annotation"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() =>
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
}
>
<Download className="h-4 w-4" />
</button>
</div>
</header>
<main
className="space-y-4 px-4 py-4"
style={{
cursor:
tool === "pan"
? grabbing
? "grabbing"
: "grab"
: tool === "erase"
? ERASER_CURSOR
: undefined,
}}
onPointerDown={(e) => {
if (tool !== "pan" || e.button !== 0) return;
panRef.current = { x: e.clientX, y: e.clientY };
e.currentTarget.setPointerCapture(e.pointerId);
setGrabbing(true);
}}
onPointerMove={(e) => {
const p = panRef.current;
if (!p) return;
window.scrollBy(p.x - e.clientX, p.y - e.clientY);
panRef.current = { x: e.clientX, y: e.clientY };
}}
onPointerUp={() => {
panRef.current = null;
setGrabbing(false);
}}
onPointerCancel={() => {
panRef.current = null;
setGrabbing(false);
}}
>
{pageSizes.length === 0 ? (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
กำลังโหลดเอกสาร…
</div>
) : (
pageSizes.map((size, i) => {
const w = Math.ceil(size.width * scale);
const h = Math.ceil(size.height * scale);
const mounted = renderPages.has(i);
return (
<div
key={i}
data-page={i}
ref={(el) => {
pageWrapRefs.current[i] = el;
}}
className="relative mx-auto bg-white shadow-md"
style={{ width: w, height: h }}
onMouseUp={() => handleMouseUpOnPage(i)}
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
ซ่อม 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>
2026-09-11 13:28:35 +07:00
onMouseDown={(e) => {
// จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects)
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
ซ่อม 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>
2026-09-11 13:28:35 +07:00
selDragStartRef.current = { x: e.clientX, y: e.clientY };
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}}
onMouseMove={(e) => {
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
if (TEXT_SELECTION_TOOLS.has(tool) && e.buttons === 1) {
ซ่อม 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>
2026-09-11 13:28:35 +07:00
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}
handlePageHoverMove(e, i);
}}
onMouseLeave={() => setHoveredId(null)}
>
{mounted && pdfjs && doc ? (
<PdfPage
pdfjs={pdfjs}
doc={doc}
pageNumber={i + 1}
width={w}
height={h}
tool={tool}
shapeKind={shapeKind}
drawStyle={drawStyle}
shapeStyle={shapeStyle}
textboxStyle={textboxStyle}
pageBaseWidth={basePageWidth}
liveMark={liveMark}
liveMarkStyle={{
color: highlightColor,
style: highlightStyle,
...(highlightOpacityPct < 100
? { opacity: highlightOpacityPct / 100 }
: {}),
}}
ซ่อม 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>
2026-09-11 13:28:35 +07:00
selectPreview={selectPreview}
annotations={visibleAnnotations
.filter((a) => a.pageIndex === i)
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
.map((a) =>
a.id === selectedId && previewData
? { ...a, data: previewData }
: a,
)}
replies={visibleReplies}
openPinId={openPinId}
ownUserId={user?.userId ?? ""}
selectedId={selectedId}
editingCommentId={editingCommentId}
commentEditDraft={commentEditDraft}
onCommentEditDraft={setCommentEditDraft}
onStartCommentEdit={startCommentEdit}
textbox={{
composer: textboxComposer,
commitComposer: commitTextboxComposer,
cancelComposer: () => setTextboxComposer(null),
editingId: editingTextboxId,
startEdit: startTextboxEdit,
commitEdit: commitTextboxEdit,
}}
onSelectAnnotation={handleSelectAnnotation}
onPinToggle={handlePinToggle}
onAnnotationPreview={setPreviewData}
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onSetResolved={(id, resolved) => {
void handleSetResolved(id, resolved);
}}
onAnnotationBookmark={handleAnnotationBookmark}
onCreateReply={(annotationId, message) => {
void handleCreateReply(annotationId, message);
}}
onDeleteReply={(id) => {
void handleDeleteReply(id);
}}
onStrokeEnd={(points, widths) => {
if (!user) return;
void handleCreateAnnotation(i, "DRAW", {
strokeColor: drawStyle.color,
strokeWidth: drawStyle.strokeWidthPct,
paths: points,
opacity: drawStyle.opacity,
...(widths ? { widths } : {}),
...(drawPen === "highlighter" ? { pen: drawPen } : {}),
});
}}
onShapeEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "SHAPE", data);
}}
onRedactionEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data);
}}
onErase={(id) => void handleDeleteAnnotation(id)}
/>
) : null}
{/* N1: tooltip เมื่อ mouse ชี้ annotation — ใครทำ + โน้ตที่ฝากไว้
(z-5 เหนือ pins, pointer-events none ไม่บังการคลิกใด ๆ) */}
{(() => {
const hovered = visibleAnnotations.find((a) => a.id === hoveredId);
if (!hovered || hovered.pageIndex !== i) return null;
const box = annotationBBox(hovered, { width: w, height: h });
const note = annotationNote(hovered);
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
const flipLeft = box.x + box.width + 264 > w;
return (
<div
className="pointer-events-none absolute z-[5] w-max max-w-[256px] rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
style={{
...(flipLeft
? { right: w - box.x + 8 }
: { left: box.x + box.width + 8 }),
top: Math.max(box.y - 4, 2),
}}
>
<p className="font-medium text-slate-700">
{annotationTypeLabel(hovered)} <span className="text-slate-300">·</span>{" "}
{hovered.userName}
</p>
{note && (
<p className="mt-0.5 whitespace-pre-wrap break-words text-slate-500">
{note}
</p>
)}
</div>
);
})()}
</div>
);
})
)}
</main>
</div>
{composer && (
<div
className="fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
style={{
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)),
}}
>
<textarea
autoFocus
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="พิมพ์ข้อความ comment"
className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
onKeyDown={(e) => {
if (e.key === "Escape") setComposer(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
void handleComposerSave();
}
}}
/>
<div className="mt-2 flex justify-end gap-2 text-xs">
<button
type="button"
className="rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
onClick={() => setComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!draft.trim()}
className="rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
onClick={() => void handleComposerSave()}
>
บันทึก
</button>
</div>
</div>
)}
</div>
);
}