All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Co-Authored-By: Claude Code <noreply@anthropic.com>
3448 lines
168 KiB
TypeScript
3448 lines
168 KiB
TypeScript
"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,
|
||
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,
|
||
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"];
|
||
|
||
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
|
||
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
|
||
* และตัดยาวไม่เกิน 120 ตัวอักษร */
|
||
function normalizeBookmarkTitle(s: string): string {
|
||
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;
|
||
}
|
||
|
||
/** กรอง 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 },
|
||
];
|
||
|
||
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
|
||
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
|
||
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
|
||
"DRAW",
|
||
"SHAPE",
|
||
"TEXTBOX",
|
||
"STAMP",
|
||
"HIGHLIGHT",
|
||
"REDACTION",
|
||
]);
|
||
|
||
/** 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`;
|
||
|
||
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS
|
||
* (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */
|
||
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
|
||
pencil: Pencil,
|
||
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,
|
||
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 };
|
||
/** 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,
|
||
),
|
||
)}
|
||
{/* 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}
|
||
stamps={inkStamps}
|
||
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);
|
||
// 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());
|
||
}, []);
|
||
|
||
// โหลดเอกสาร: 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";
|
||
// โหลดผ่าน /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();
|
||
};
|
||
}, [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]);
|
||
|
||
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
|
||
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
|
||
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
|
||
const mainRef = useRef<HTMLElement>(null);
|
||
const fitWidthDoneRef = useRef(false);
|
||
useEffect(() => {
|
||
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
|
||
fitWidthDoneRef.current = true;
|
||
const avail = (mainRef.current?.clientWidth ?? 0) - 32;
|
||
const pageW = pageSizes[0].width;
|
||
if (avail > 0 && pageW > avail) {
|
||
setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1));
|
||
}
|
||
}, [pageSizes]);
|
||
|
||
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)
|
||
const shortcutHandlersRef = useRef({
|
||
switchTool,
|
||
openBookmarkComposer,
|
||
undoHistory,
|
||
redoHistory,
|
||
deleteSelected: () => {},
|
||
});
|
||
useEffect(() => {
|
||
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
|
||
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;
|
||
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({
|
||
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
|
||
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],
|
||
);
|
||
// สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ
|
||
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
|
||
if (AUTO_SELECT_TYPES.has(type)) {
|
||
setSelectedId(saved.id);
|
||
setPreviewData(null);
|
||
}
|
||
// 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("");
|
||
|
||
/** เส้นทางลากเมาส์ของ 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);
|
||
|
||
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
|
||
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
|
||
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection แทน — ปุ่มอ่านจาก
|
||
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
|
||
const [touchConfirm, setTouchConfirm] = useState<{
|
||
pageIndex: number;
|
||
x: number; // จุดยึดกึ่งกลางแนวนอนของแถบ (px อิงหน้า)
|
||
y: number; // ขอบบนของแถบ (px อิงหน้า)
|
||
} | null>(null);
|
||
const touchSelRef = useRef<{
|
||
pageIndex: number;
|
||
rects: HighlightData["rects"];
|
||
text: string;
|
||
} | null>(null);
|
||
const touchSelTimerRef = useRef<number | null>(null);
|
||
const touchHideTimerRef = useRef<number | 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);
|
||
// ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที)
|
||
setBookmarkComposer({
|
||
...pos,
|
||
title: lastSelectionTextRef.current || `หน้า ${pos.pageIndex + 1}`,
|
||
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";
|
||
const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "";
|
||
setBookmarkError(null);
|
||
setBookmarkComposer({
|
||
pageIndex: a.pageIndex,
|
||
yOffset: clamp(yOffsetPct, 0, 100),
|
||
title: text || `หน้า ${a.pageIndex + 1}`,
|
||
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 แรก
|
||
* ใช้ทั้งตอน 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;
|
||
// จำกัดเฉพาะแนวเส้นทางที่ลากเมาส์จริง — 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();
|
||
// จำเส้นทางลาก (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 ต่อบรรทัดก่อนบันทึก
|
||
// ข้อความกรองให้เหลือเฉพาะตัวอักษรใน rects ข้างบน (DOM range ไทยครอบคำเกิน
|
||
// บริเวณที่ลาก) และตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4)
|
||
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
|
||
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
|
||
first: sweptRects[0],
|
||
last: sweptRects[sweptRects.length - 1],
|
||
};
|
||
}
|
||
|
||
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
||
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
||
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
|
||
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
|
||
useEffect(() => {
|
||
if (!TEXT_SELECTION_TOOLS.has(tool)) {
|
||
setLiveMark(null);
|
||
setSelectPreview(null);
|
||
setTouchConfirm(null);
|
||
touchSelRef.current = null;
|
||
return;
|
||
}
|
||
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
|
||
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์" ลอยใต้ selection
|
||
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
|
||
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
|
||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||
// ซ่อนปุ่ม + ล้าง snapshot แบบหน่วงเวลา: บน iOS การแตะปุ่มเองทำให้ OS เคลียร์
|
||
// selection ก่อน tap จะถึงปุ่ม (selectionchange collapsed ยิงก่อน pointerdown
|
||
// ของปุ่ม) — หน่วงไว้ให้ tap นั้น commit จาก snapshot ได้ก่อน ปุ่มจึงไม่หายกลางคัน
|
||
const scheduleTouchHide = () => {
|
||
if (touchHideTimerRef.current !== null) {
|
||
window.clearTimeout(touchHideTimerRef.current);
|
||
}
|
||
touchHideTimerRef.current = window.setTimeout(() => {
|
||
touchHideTimerRef.current = null;
|
||
touchSelRef.current = null;
|
||
setTouchConfirm(null);
|
||
}, 250);
|
||
};
|
||
const onSelectionChange = () => {
|
||
// selection กำลังเปลี่ยนอยู่ — ยกเลิก snapshot ที่ค้าง + การซ่อนปุ่มที่ค้าง
|
||
// (selection กลับมา = ผู้ใช้เริ่ม/ปรับ selection ใหม่ ปุ่มต้องอยู่ต่อ)
|
||
if (touchSelTimerRef.current !== null) {
|
||
window.clearTimeout(touchSelTimerRef.current);
|
||
touchSelTimerRef.current = null;
|
||
}
|
||
if (touchHideTimerRef.current !== null) {
|
||
window.clearTimeout(touchHideTimerRef.current);
|
||
touchHideTimerRef.current = null;
|
||
}
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||
setLiveMark(null);
|
||
setSelectPreview(null);
|
||
scheduleTouchHide();
|
||
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);
|
||
setSelectPreview(null);
|
||
scheduleTouchHide();
|
||
return;
|
||
}
|
||
const pi = Number(pageEl.dataset.page);
|
||
const sel = readTextSelection(pi);
|
||
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;
|
||
});
|
||
// จอสัมผัส + tool highlight — selection หยุดนิ่ง ~400ms แล้ว snapshot (rects +
|
||
// ข้อความ) ให้ปุ่มยืนยันใช้: debounce ข้ามช่วงที่ผู้ใช้ยังลาก handles อยู่
|
||
if (coarse && tool === "highlight") {
|
||
touchSelTimerRef.current = window.setTimeout(() => {
|
||
touchSelTimerRef.current = null;
|
||
const snap = readTextSelection(pi, { withText: true });
|
||
if (!snap) {
|
||
scheduleTouchHide();
|
||
return;
|
||
}
|
||
touchSelRef.current = {
|
||
pageIndex: pi,
|
||
rects: snap.rects,
|
||
text: snap.text,
|
||
};
|
||
// ชื่อ bookmark default จาก selection เหมือน mouseup ของ desktop
|
||
lastSelectionTextRef.current = normalizeBookmarkTitle(snap.text);
|
||
const pr = pageEl.getBoundingClientRect();
|
||
// แถบลอยใต้บรรทัดสุดท้ายของ selection — ด้านบนถูกเมนู/handles ของ OS
|
||
// ยึดไว้ ใกล้ขอบล่างหน้าเกิน → พลิกขึ้นเหนือ rect แรกแทน
|
||
const below = snap.last.bottom - pr.top + 8;
|
||
setTouchConfirm({
|
||
pageIndex: pi,
|
||
x: snap.first.left + snap.first.width / 2 - pr.left,
|
||
y: below + 40 <= pr.height ? below : snap.first.top - pr.top - 44,
|
||
});
|
||
}, 400);
|
||
}
|
||
};
|
||
document.addEventListener("selectionchange", onSelectionChange);
|
||
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
|
||
// onMouseUp ของหน้า (root ของ React อยู่ใต้ document) ค่าจึงยังใช้ได้ตอนสร้างมาร์ก
|
||
const onMouseUpDoc = () => {
|
||
selDragStartRef.current = null;
|
||
selDragEndRef.current = null;
|
||
};
|
||
document.addEventListener("mouseup", onMouseUpDoc);
|
||
return () => {
|
||
document.removeEventListener("selectionchange", onSelectionChange);
|
||
document.removeEventListener("mouseup", onMouseUpDoc);
|
||
if (touchSelTimerRef.current !== null) {
|
||
window.clearTimeout(touchSelTimerRef.current);
|
||
touchSelTimerRef.current = null;
|
||
}
|
||
if (touchHideTimerRef.current !== null) {
|
||
window.clearTimeout(touchHideTimerRef.current);
|
||
touchHideTimerRef.current = null;
|
||
}
|
||
};
|
||
}, [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;
|
||
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]);
|
||
|
||
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
|
||
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */
|
||
function commitHighlight(
|
||
pageIndex: number,
|
||
sel: { rects: HighlightData["rects"]; text: string },
|
||
) {
|
||
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 } : {}),
|
||
});
|
||
}
|
||
|
||
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์" ลอยใต้ selection — commit จาก snapshot ใน ref
|
||
* (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม —
|
||
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
|
||
function handleTouchConfirmHighlight() {
|
||
const snap = touchSelRef.current;
|
||
touchSelRef.current = null;
|
||
if (touchHideTimerRef.current !== null) {
|
||
window.clearTimeout(touchHideTimerRef.current);
|
||
touchHideTimerRef.current = null;
|
||
}
|
||
setTouchConfirm(null);
|
||
if (!snap) return;
|
||
commitHighlight(snap.pageIndex, snap);
|
||
}
|
||
|
||
function handleMouseUpOnPage(pageIndex: number) {
|
||
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
|
||
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
|
||
// อ่านครั้งเดียวพร้อมข้อความ (กรองตาม 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;
|
||
commitHighlight(pageIndex, sel);
|
||
}
|
||
|
||
// ─── 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 (เฉพาะ < lg)
|
||
z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */}
|
||
{sidebarOpen && (
|
||
<div
|
||
className="fixed inset-0 z-[45] bg-slate-900/30 lg:hidden"
|
||
aria-hidden
|
||
onClick={() => setSidebarOpen(false)}
|
||
/>
|
||
)}
|
||
{/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
|
||
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
|
||
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
|
||
<aside
|
||
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
|
||
sidebarOpen
|
||
? "max-lg:translate-x-0"
|
||
: "max-lg:pointer-events-none max-lg:-translate-x-full lg: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}
|
||
selectedId={selectedId}
|
||
/>
|
||
</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
|
||
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
|
||
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}>
|
||
ใต้ "{h.title}"
|
||
</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
|
||
ref={mainRef}
|
||
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)}
|
||
onMouseDown={(e) => {
|
||
// จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects)
|
||
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
|
||
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) {
|
||
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 }
|
||
: {}),
|
||
}}
|
||
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>
|
||
);
|
||
})()}
|
||
|
||
{/* จอสัมผัส: ปุ่มยืนยันสร้าง highlight หลังคลุมข้อความเสร็จ (บน
|
||
touch ไม่มี mouseup) — ลอยใต้ selection แตะแล้ว commit จาก
|
||
snapshot; commit ที่ pointerdown (เร็วกว่า click) +
|
||
stopPropagation กัน bubble ไป handler ของหน้า */}
|
||
{touchConfirm?.pageIndex === i && (
|
||
<div
|
||
className="absolute z-[5] select-none"
|
||
style={{
|
||
left: touchConfirm.x,
|
||
top: touchConfirm.y,
|
||
transform: "translate(-50%, 0)",
|
||
}}
|
||
onPointerDown={(e) => {
|
||
e.stopPropagation();
|
||
handleTouchConfirmHighlight();
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="flex touch-manipulation items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-lg"
|
||
>
|
||
<span
|
||
className="h-3.5 w-3.5 rounded-[2px]"
|
||
style={{ backgroundColor: highlightColor }}
|
||
/>
|
||
ไฮไลต์
|
||
</button>
|
||
</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>
|
||
);
|
||
}
|