2919 lines
134 KiB
TypeScript
2919 lines
134 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,
|
|||
|
|
Bold,
|
|||
|
|
BookmarkPlus,
|
|||
|
|
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,
|
|||
|
|
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 { annotationBBox, hitTestPath, hitTestShape } 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 {
|
|||
|
|
ColorSwatches,
|
|||
|
|
HIGHLIGHT_STYLE_BTNS,
|
|||
|
|
NumberCombo,
|
|||
|
|
PanelRow,
|
|||
|
|
} from "./ToolControls";
|
|||
|
|
|
|||
|
|
type Tool =
|
|||
|
|
| "select"
|
|||
|
|
| "pan"
|
|||
|
|
| "highlight"
|
|||
|
|
| "draw"
|
|||
|
|
| "comment"
|
|||
|
|
| "shape"
|
|||
|
|
| "erase"
|
|||
|
|
| "redact"
|
|||
|
|
| "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 ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ยุบ whitespace และตัดยาวไม่เกิน 120 ตัวอักษร */
|
|||
|
|
function normalizeBookmarkTitle(s: string): string {
|
|||
|
|
const t = 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
|||
|
|
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
|
|||
|
|
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
|
|||
|
|
{ key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter },
|
|||
|
|
{ key: "draw", label: "วาดเส้น", Icon: Pencil },
|
|||
|
|
{ key: "comment", label: "วาง comment", Icon: MessageSquarePlus },
|
|||
|
|
{ key: "shape", label: "รูปทรง", Icon: Shapes },
|
|||
|
|
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
|
|||
|
|
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
|
|||
|
|
{ key: "textbox", label: "กล่องข้อความ", Icon: Type },
|
|||
|
|
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน
|
|||
|
|
* แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */
|
|||
|
|
const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
|||
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#334155" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>',
|
|||
|
|
)}") 2 17, auto`;
|
|||
|
|
|
|||
|
|
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */
|
|||
|
|
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
|
|||
|
|
pencil: Pencil,
|
|||
|
|
highlighter: Highlighter,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** ชนิดรูปทรงย่อยของ 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,
|
|||
|
|
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";
|
|||
|
|
};
|
|||
|
|
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
|||
|
|
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
|||
|
|
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
|
|||
|
|
/** ความกว้างหน้าแรกที่ 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,
|
|||
|
|
),
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
|
|||
|
|
<div
|
|||
|
|
ref={textLayerRef}
|
|||
|
|
className="textLayer absolute inset-0 z-[2]"
|
|||
|
|
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */}
|
|||
|
|
<InkLayer
|
|||
|
|
size={{ width, height }}
|
|||
|
|
strokes={inkStrokes}
|
|||
|
|
shapes={inkShapes}
|
|||
|
|
redactions={inkRedactions}
|
|||
|
|
active={inkActive}
|
|||
|
|
nextStroke={{
|
|||
|
|
color: drawStyle.color,
|
|||
|
|
strokeWidthPct: drawStyle.strokeWidthPct,
|
|||
|
|
opacity: drawStyle.opacity,
|
|||
|
|
}}
|
|||
|
|
shapeTool={
|
|||
|
|
tool === "shape"
|
|||
|
|
? { kind: shapeKind, color: shapeStyle.color, strokeWidthPct: shapeStyle.strokeWidthPct }
|
|||
|
|
: null
|
|||
|
|
}
|
|||
|
|
onStrokeEnd={onStrokeEnd}
|
|||
|
|
onShapeEnd={onShapeEnd}
|
|||
|
|
onRedactionEnd={onRedactionEnd}
|
|||
|
|
eraseMode={tool === "erase"}
|
|||
|
|
redactMode={tool === "redact"}
|
|||
|
|
ownUserId={ownUserId}
|
|||
|
|
onErase={onErase}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{/* z-4: TEXTBOX overlay (F17/B2) — ก่อน comment pins ใน DOM */}
|
|||
|
|
<TextboxStampLayer
|
|||
|
|
size={{ width, height }}
|
|||
|
|
textboxes={inkTextboxes}
|
|||
|
|
stamps={inkStamps}
|
|||
|
|
enabled={!overlayFrozen}
|
|||
|
|
ownUserId={ownUserId}
|
|||
|
|
editingId={textbox.editingId}
|
|||
|
|
onStartEdit={textbox.startEdit}
|
|||
|
|
onCommitEdit={textbox.commitEdit}
|
|||
|
|
composer={
|
|||
|
|
textbox.composer && textbox.composer.pageIndex === pageNumber - 1
|
|||
|
|
? {
|
|||
|
|
position: textbox.composer.position,
|
|||
|
|
fontSizePct: textboxStyle.fontSizePct,
|
|||
|
|
color: textboxStyle.color,
|
|||
|
|
bold: textboxStyle.bold,
|
|||
|
|
opacity: textboxStyle.opacity,
|
|||
|
|
align: textboxStyle.align,
|
|||
|
|
}
|
|||
|
|
: 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);
|
|||
|
|
const [composer, setComposer] = useState<Composer | null>(null);
|
|||
|
|
const [draft, setDraft] = useState("");
|
|||
|
|
const [openPinId, setOpenPinId] = useState<string | null>(null);
|
|||
|
|
// F1 แก้ไข/ย้าย/ปรับขนาด — รายการที่เลือก + data ชั่วคราวระหว่างลาก (render ที่ layer เดิม)
|
|||
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|||
|
|
// N1: annotation ที่ mouse ชี้อยู่บนหน้า — แสดง tooltip ใครทำ + โน้ต (เปลี่ยนค่าเฉพาะเมื่อรายการเปลี่ยน)
|
|||
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|||
|
|
// N2: popup บันทึก bookmark (ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ) — null = ปิด
|
|||
|
|
const [bookmarkComposer, setBookmarkComposer] = useState<{
|
|||
|
|
pageIndex: number;
|
|||
|
|
yOffset: number;
|
|||
|
|
title: string;
|
|||
|
|
note: string;
|
|||
|
|
parentId: string | null;
|
|||
|
|
} | null>(null);
|
|||
|
|
/** error ล่าสุดของการบันทึก bookmark — แสดงใน popup ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนเงียบ */
|
|||
|
|
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
|
|||
|
|
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
|
|||
|
|
const bookmarkPendingRef = useRef(false);
|
|||
|
|
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
|
|||
|
|
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
|||
|
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
|||
|
|
const [commentEditDraft, setCommentEditDraft] = useState("");
|
|||
|
|
// F17/B2: กล่องข้อความ — composer ตอนสร้าง + โหมดแก้ไขข้อความของตัวเอง (double-click)
|
|||
|
|
const [textboxComposer, setTextboxComposer] = useState<TextboxComposer | null>(null);
|
|||
|
|
const [editingTextboxId, setEditingTextboxId] = useState<string | null>(null);
|
|||
|
|
// F4 ซ่อน–แสดงรายคน — "all" | "mine" | <userId> กรองฝั่ง client ล้วน (A3)
|
|||
|
|
const [userFilter, setUserFilter] = useState<string>("all");
|
|||
|
|
// UX review 2026-09-09: สลับ sidebar — จอ < xl เป็น overlay, desktop กดซ่อนเพื่อดู PDF เต็มจอ
|
|||
|
|
// (เริ่ม true เพื่อให้ SSR เรนเดอร์แบบเปิดเหมือน desktop เดิม กัน hydration mismatch)
|
|||
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
annotations,
|
|||
|
|
bookmarks,
|
|||
|
|
replies,
|
|||
|
|
setAnnotations,
|
|||
|
|
setBookmarks,
|
|||
|
|
setReplies,
|
|||
|
|
} = useAnnotationRealtime(file.id, initialAnnotations, initialBookmarks, initialReplies);
|
|||
|
|
|
|||
|
|
// F2: undo/redo ต่อ session ต่อผู้ใช้ฝั่ง client (D10 — ไม่เก็บใน DB)
|
|||
|
|
const {
|
|||
|
|
push: pushHistory,
|
|||
|
|
undo: undoHistory,
|
|||
|
|
redo: redoHistory,
|
|||
|
|
canUndo,
|
|||
|
|
canRedo,
|
|||
|
|
} = useHistory();
|
|||
|
|
|
|||
|
|
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
|
|||
|
|
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
|
|||
|
|
const [optimisticAnnotations, applyAnnotation] = useOptimistic(
|
|||
|
|
annotations,
|
|||
|
|
(state, action: AnnotationAction) => {
|
|||
|
|
if (action.kind === "add") {
|
|||
|
|
return state.some((a) => a.id === action.row.id)
|
|||
|
|
? state
|
|||
|
|
: [...state, action.row];
|
|||
|
|
}
|
|||
|
|
if (action.kind === "update") {
|
|||
|
|
// replace ตาม id — idempotent เหมือน SSE update event (PLAN-FEAT.md §2)
|
|||
|
|
return state.map((a) => (a.id === action.row.id ? action.row : a));
|
|||
|
|
}
|
|||
|
|
return state.filter((a) => a.id !== action.id);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
// N2: การสร้าง bookmark รอผลจาก action ตรง ๆ (ไม่ optimistic) — เหลือแค่ remove
|
|||
|
|
const [optimisticBookmarks, applyBookmark] = useOptimistic(
|
|||
|
|
bookmarks,
|
|||
|
|
(state, action: BookmarkAction) =>
|
|||
|
|
action.kind === "remove" ? state.filter((b) => b.id !== action.id) : state,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// F4: รายชื่อผู้เขียน (unique ตาม userId จาก annotations — ตัวเองอยู่ใน option "ของฉัน" แล้ว)
|
|||
|
|
const users = useMemo(() => {
|
|||
|
|
const seen = new Map<string, string>();
|
|||
|
|
for (const a of optimisticAnnotations) {
|
|||
|
|
if (!seen.has(a.userId) && a.userId !== user?.userId) seen.set(a.userId, a.userName);
|
|||
|
|
}
|
|||
|
|
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
|
|||
|
|
}, [optimisticAnnotations, user]);
|
|||
|
|
|
|||
|
|
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
|
|||
|
|
const visibleAnnotations = useMemo(() => {
|
|||
|
|
if (userFilter === "all") return optimisticAnnotations;
|
|||
|
|
const target = userFilter === "mine" ? user?.userId : userFilter;
|
|||
|
|
return optimisticAnnotations.filter((a) => a.userId === target);
|
|||
|
|
}, [optimisticAnnotations, userFilter, user]);
|
|||
|
|
|
|||
|
|
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
|
|||
|
|
// (O(points) ของเส้น DRAW) ครั้งเดียวต่อ list แทนคำนวณใหม่ทุก mousemove
|
|||
|
|
const hoverBBoxPct = useMemo(() => {
|
|||
|
|
const m = new Map<AnnotationDTO["id"], Rect>();
|
|||
|
|
for (const a of visibleAnnotations) {
|
|||
|
|
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
|
|||
|
|
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
|
|||
|
|
}
|
|||
|
|
return m;
|
|||
|
|
}, [visibleAnnotations]);
|
|||
|
|
|
|||
|
|
// F9: จำนวน reply ต่อ comment (สำหรับ sidebar)
|
|||
|
|
const replyCounts = useMemo(() => {
|
|||
|
|
const counts: Record<string, number> = {};
|
|||
|
|
for (const r of replies) {
|
|||
|
|
counts[r.annotationId] = (counts[r.annotationId] ?? 0) + 1;
|
|||
|
|
}
|
|||
|
|
return counts;
|
|||
|
|
}, [replies]);
|
|||
|
|
|
|||
|
|
// F9: replies ของแม่ที่ยังแสดงอยู่ (ตาม filter ของ F4) — กรองครั้งเดียว ไม่ใช่ทุกหน้า
|
|||
|
|
const visibleReplies = useMemo(
|
|||
|
|
() =>
|
|||
|
|
replies.filter((r) => visibleAnnotations.some((a) => a.id === r.annotationId)),
|
|||
|
|
[replies, visibleAnnotations],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
|
|||
|
|
useEffect(() => {
|
|||
|
|
setUser(getLocalUser());
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// UX review 2026-09-09: จอแคบกว่า xl (< 1280px) sidebar เป็น overlay — ปิดอัตโนมัติตอน mount
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!window.matchMedia("(min-width: 1280px)").matches) setSidebarOpen(false);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
const pdfjs = await import("pdfjs-dist");
|
|||
|
|
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
|||
|
|
const doc = await pdfjs.getDocument({ url: file.pdfUrl }).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.pdfUrl]);
|
|||
|
|
|
|||
|
|
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|||
|
|
|
|||
|
|
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
|
|||
|
|
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
|
|||
|
|
useEffect(() => {
|
|||
|
|
const n = pageSizes.length;
|
|||
|
|
if (n === 0) return;
|
|||
|
|
const visible = new Set<number>();
|
|||
|
|
const io = new IntersectionObserver(
|
|||
|
|
(entries) => {
|
|||
|
|
for (const en of entries) {
|
|||
|
|
const p = Number((en.target as HTMLElement).dataset.page);
|
|||
|
|
if (en.isIntersecting) visible.add(p);
|
|||
|
|
else visible.delete(p);
|
|||
|
|
}
|
|||
|
|
if (visible.size === 0) return;
|
|||
|
|
const next = new Set<number>();
|
|||
|
|
for (const p of Array.from(visible)) {
|
|||
|
|
for (const d of [-1, 0, 1]) {
|
|||
|
|
const q = p + d;
|
|||
|
|
if (q >= 0 && q < n) next.add(q);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setRenderPages(next);
|
|||
|
|
},
|
|||
|
|
{ rootMargin: "100% 0px" },
|
|||
|
|
);
|
|||
|
|
for (const el of pageWrapRefs.current) {
|
|||
|
|
if (el) io.observe(el);
|
|||
|
|
}
|
|||
|
|
return () => io.disconnect();
|
|||
|
|
}, [pageSizes]);
|
|||
|
|
|
|||
|
|
// หน้าปัจจุบัน = หน้าที่ midpoint ใกล้กึ่งกลาง viewport สุด (สำหรับ bookmark — PLAN.md §6.4)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (pageSizes.length === 0) return;
|
|||
|
|
const onScroll = () => {
|
|||
|
|
const center = window.scrollY + window.innerHeight / 2;
|
|||
|
|
let best = 0;
|
|||
|
|
let bestDist = Infinity;
|
|||
|
|
for (let i = 0; i < pageWrapRefs.current.length; i++) {
|
|||
|
|
const el = pageWrapRefs.current[i];
|
|||
|
|
if (!el) continue;
|
|||
|
|
const r = el.getBoundingClientRect();
|
|||
|
|
const mid = r.top + window.scrollY + r.height / 2;
|
|||
|
|
const d = Math.abs(mid - center);
|
|||
|
|
if (d < bestDist) {
|
|||
|
|
bestDist = d;
|
|||
|
|
best = i;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setCurrentPage(best);
|
|||
|
|
};
|
|||
|
|
onScroll();
|
|||
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|||
|
|
return () => window.removeEventListener("scroll", onScroll);
|
|||
|
|
}, [pageSizes]);
|
|||
|
|
|
|||
|
|
// F12/C4: deep link `?page=N` (1-based) และ/หรือ `?ann=<uuid>` — อ่าน
|
|||
|
|
// window.location.search ครั้งเดียวหลัง pageSizes พร้อม (ไม่ใช้ useSearchParams
|
|||
|
|
// เลี่ยงข้อจำกัด prerender ของ Next 14) — placeholder มีขนาดจริงทุกหน้าจึงคำนวณ
|
|||
|
|
// absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark — §5)
|
|||
|
|
const deepLinkDoneRef = useRef(false);
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (pageSizes.length === 0 || deepLinkDoneRef.current) return;
|
|||
|
|
deepLinkDoneRef.current = true;
|
|||
|
|
const params = new URLSearchParams(window.location.search);
|
|||
|
|
let targetPage: number | null = null;
|
|||
|
|
const annId = params.get("ann");
|
|||
|
|
if (annId) {
|
|||
|
|
const a = annotations.find((x) => x.id === annId);
|
|||
|
|
if (a) {
|
|||
|
|
// กระโดด + เลือกด้วยกลไกเดียวกับคลิกใน sidebar (เลื่อนถึง bbox ใต้ header)
|
|||
|
|
targetPage = a.pageIndex;
|
|||
|
|
goToAnnotation(a);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const pageParam = Number(params.get("page"));
|
|||
|
|
if (targetPage === null && Number.isInteger(pageParam) && pageParam >= 1) {
|
|||
|
|
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
|
|||
|
|
const el = pageWrapRefs.current[targetPage];
|
|||
|
|
if (el) {
|
|||
|
|
scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [pageSizes, annotations]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setDraft("");
|
|||
|
|
}, [composer]);
|
|||
|
|
|
|||
|
|
// F5/F18 + DRAW: dropdown บนปุ่ม tool (ชนิดปากกา/รูปทรง/ตรายาง) — ปิดเมื่อคลิกข้างนอก
|
|||
|
|
// ส่วน Esc ปิดแค่เมนูก่อน (stopPropagation กันชนคีย์ลัด F3 ที่จะ deselect ทุกอย่างพร้อมกัน)
|
|||
|
|
const stampMenuRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
const shapeMenuRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
const drawMenuRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
const textboxMenuRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
const highlightMenuRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
// tool pan — ลากเลื่อนหน้า: เก็บตำแหน่ง pointer ล่าสุดระหว่างลาก (scrollBy สวนทางการเลื่อน)
|
|||
|
|
const panRef = useRef<{ x: number; y: number } | null>(null);
|
|||
|
|
const [grabbing, setGrabbing] = useState(false);
|
|||
|
|
useEffect(() => {
|
|||
|
|
const menus = [
|
|||
|
|
{ open: stampMenuOpen, ref: stampMenuRef, close: () => setStampMenuOpen(false) },
|
|||
|
|
{ open: shapeMenuOpen, ref: shapeMenuRef, close: () => setShapeMenuOpen(false) },
|
|||
|
|
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
|
|||
|
|
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
|
|||
|
|
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
|
|||
|
|
];
|
|||
|
|
if (!menus.some((m) => m.open)) return;
|
|||
|
|
const onMouseDown = (e: MouseEvent) => {
|
|||
|
|
for (const m of menus) {
|
|||
|
|
if (m.open && !m.ref.current?.contains(e.target as Node)) m.close();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
const onKey = (e: KeyboardEvent) => {
|
|||
|
|
if (e.key === "Escape") {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
for (const m of menus) m.close();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
document.addEventListener("mousedown", onMouseDown);
|
|||
|
|
document.addEventListener("keydown", onKey);
|
|||
|
|
return () => {
|
|||
|
|
document.removeEventListener("mousedown", onMouseDown);
|
|||
|
|
document.removeEventListener("keydown", onKey);
|
|||
|
|
};
|
|||
|
|
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen]);
|
|||
|
|
|
|||
|
|
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
|
|||
|
|
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
|||
|
|
// contenteditable (PLAN-FEAT.md §3 F3, §5)
|
|||
|
|
const shortcutHandlersRef = useRef({ switchTool, openBookmarkComposer, undoHistory, redoHistory });
|
|||
|
|
useEffect(() => {
|
|||
|
|
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
|
|||
|
|
shortcutHandlersRef.current = { switchTool, openBookmarkComposer, undoHistory, redoHistory };
|
|||
|
|
});
|
|||
|
|
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;
|
|||
|
|
switch (e.key.toLowerCase()) {
|
|||
|
|
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
|
|||
|
|
h.switchTool("highlight");
|
|||
|
|
break;
|
|||
|
|
case "d":
|
|||
|
|
h.switchTool("draw");
|
|||
|
|
break;
|
|||
|
|
case "c":
|
|||
|
|
h.switchTool("comment");
|
|||
|
|
break;
|
|||
|
|
case "b":
|
|||
|
|
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
window.addEventListener("keydown", onKey);
|
|||
|
|
return () => window.removeEventListener("keydown", onKey);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// ─── เขียนผ่าน Server Actions + optimistic (invariant #4, #9) ──────────────
|
|||
|
|
|
|||
|
|
// FIX-2: create ที่ยังรอ network อยู่ (key = temp id) —
|
|||
|
|
// ลบทันทีก่อน action เสร็จ → mark cancelled แล้วฝั่ง create ลบ row จริงต่อเมื่อ
|
|||
|
|
// action กลับมา (id ชั่วคราวยังไม่มีใน DB — ยิง delete ตอนนี้จะพัง แล้วรายการ
|
|||
|
|
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
|
|||
|
|
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
|
|||
|
|
|
|||
|
|
async function handleCreateAnnotation(
|
|||
|
|
pageIndex: number,
|
|||
|
|
type: AnnotationType,
|
|||
|
|
data: AnnotationData,
|
|||
|
|
) {
|
|||
|
|
if (!user) return;
|
|||
|
|
const now = new Date().toISOString();
|
|||
|
|
const temp: AnnotationDTO = {
|
|||
|
|
id: crypto.randomUUID(),
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex,
|
|||
|
|
userId: user.userId,
|
|||
|
|
userName: user.userName,
|
|||
|
|
type,
|
|||
|
|
data,
|
|||
|
|
createdAt: now,
|
|||
|
|
updatedAt: now,
|
|||
|
|
resolved: false,
|
|||
|
|
resolvedAt: null,
|
|||
|
|
};
|
|||
|
|
const pending = { cancelled: false };
|
|||
|
|
pendingCreatesRef.current.set(temp.id, pending);
|
|||
|
|
await startTransition(async () => {
|
|||
|
|
applyAnnotation({ kind: "add", row: temp });
|
|||
|
|
try {
|
|||
|
|
const saved = await createAnnotation({
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex,
|
|||
|
|
userId: user.userId,
|
|||
|
|
userName: user.userName,
|
|||
|
|
type,
|
|||
|
|
data,
|
|||
|
|
});
|
|||
|
|
if (pending.cancelled) {
|
|||
|
|
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
|
|||
|
|
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
|
|||
|
|
await deleteAnnotation(saved.id, user.userId);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setAnnotations((prev) =>
|
|||
|
|
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved],
|
|||
|
|
);
|
|||
|
|
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
|
|||
|
|
let currentId = saved.id;
|
|||
|
|
pushHistory({
|
|||
|
|
undo: async () => {
|
|||
|
|
await deleteAnnotation(currentId, user.userId);
|
|||
|
|
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
|
|||
|
|
},
|
|||
|
|
redo: async () => {
|
|||
|
|
const re = await createAnnotation({
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex,
|
|||
|
|
userId: user.userId,
|
|||
|
|
userName: user.userName,
|
|||
|
|
type,
|
|||
|
|
data,
|
|||
|
|
});
|
|||
|
|
currentId = re.id;
|
|||
|
|
setAnnotations((prev) =>
|
|||
|
|
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
|||
|
|
} finally {
|
|||
|
|
pendingCreatesRef.current.delete(temp.id);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteAnnotation(id: string) {
|
|||
|
|
if (!user) return;
|
|||
|
|
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
|
|||
|
|
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
|
|||
|
|
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
|
|||
|
|
// optimistic row จะหายเองเมื่อ transition ของ create จบลง
|
|||
|
|
const pending = pendingCreatesRef.current.get(id);
|
|||
|
|
if (pending) {
|
|||
|
|
pending.cancelled = true;
|
|||
|
|
setSelectedId(null);
|
|||
|
|
setPreviewData(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
|
|||
|
|
setSelectedId(null);
|
|||
|
|
setPreviewData(null);
|
|||
|
|
await startTransition(async () => {
|
|||
|
|
applyAnnotation({ kind: "remove", id });
|
|||
|
|
try {
|
|||
|
|
await deleteAnnotation(id, user.userId);
|
|||
|
|
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
|||
|
|
if (target) {
|
|||
|
|
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
|
|||
|
|
let currentId: string | null = null;
|
|||
|
|
pushHistory({
|
|||
|
|
undo: async () => {
|
|||
|
|
const re = await createAnnotation({
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex: target.pageIndex,
|
|||
|
|
userId: target.userId,
|
|||
|
|
userName: target.userName,
|
|||
|
|
type: target.type,
|
|||
|
|
data: target.data,
|
|||
|
|
});
|
|||
|
|
currentId = re.id;
|
|||
|
|
setAnnotations((prev) =>
|
|||
|
|
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
redo: async () => {
|
|||
|
|
if (!currentId) return;
|
|||
|
|
await deleteAnnotation(currentId, user.userId);
|
|||
|
|
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("ลบ annotation ไม่สำเร็จ", err);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
|
|||
|
|
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
|
|||
|
|
if (!user) return;
|
|||
|
|
const current = annotations.find((a) => a.id === id);
|
|||
|
|
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
|
|||
|
|
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
|
|||
|
|
setPreviewData(null);
|
|||
|
|
await startTransition(async () => {
|
|||
|
|
applyAnnotation({
|
|||
|
|
kind: "update",
|
|||
|
|
row: { ...current, data, updatedAt: new Date().toISOString() },
|
|||
|
|
});
|
|||
|
|
try {
|
|||
|
|
const saved = await updateAnnotation(id, user.userId, data);
|
|||
|
|
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
|||
|
|
pushHistory({
|
|||
|
|
undo: async () => {
|
|||
|
|
const up = await updateAnnotation(id, user.userId, before);
|
|||
|
|
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
|
|||
|
|
},
|
|||
|
|
redo: async () => {
|
|||
|
|
const up = await updateAnnotation(id, user.userId, data);
|
|||
|
|
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
|
|||
|
|
// เพราะ undo ผ่าน updateAnnotation จำกัดเจ้าของ และสเปก F2 ก็ไม่รวม resolve) ──
|
|||
|
|
|
|||
|
|
async function handleSetResolved(id: string, resolved: boolean) {
|
|||
|
|
const current = annotations.find((a) => a.id === id);
|
|||
|
|
if (!current) return;
|
|||
|
|
const now = new Date().toISOString();
|
|||
|
|
await startTransition(async () => {
|
|||
|
|
applyAnnotation({
|
|||
|
|
kind: "update",
|
|||
|
|
row: {
|
|||
|
|
...current,
|
|||
|
|
resolved,
|
|||
|
|
resolvedAt: resolved ? now : null,
|
|||
|
|
updatedAt: now,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
try {
|
|||
|
|
const saved = await setAnnotationResolved(id, resolved);
|
|||
|
|
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function handleCreateReply(annotationId: string, message: string) {
|
|||
|
|
if (!user) return;
|
|||
|
|
try {
|
|||
|
|
const saved = await createReply({
|
|||
|
|
annotationId,
|
|||
|
|
userId: user.userId,
|
|||
|
|
userName: user.userName,
|
|||
|
|
message,
|
|||
|
|
});
|
|||
|
|
setReplies((prev) =>
|
|||
|
|
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
|
|||
|
|
);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("ตอบ comment ไม่สำเร็จ", err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteReply(id: string) {
|
|||
|
|
if (!user) return;
|
|||
|
|
try {
|
|||
|
|
await deleteReply(id, user.userId);
|
|||
|
|
setReplies((prev) => prev.filter((r) => r.id !== id));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("ลบคำตอบไม่สำเร็จ", err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Bookmarks (N2: หมุดหัวข้อใหญ่ + sub-bookmark + โน้ต) ────────────────────
|
|||
|
|
|
|||
|
|
/** ข้อความที่เพิ่งคลุมบนหน้าล่าสุด — ชื่อ default ของ popup bookmark (จาก mouseup บนหน้า) */
|
|||
|
|
const lastSelectionTextRef = useRef("");
|
|||
|
|
|
|||
|
|
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
|
|||
|
|
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
|
|||
|
|
function bookmarkDepthOf(b: BookmarkDTO): number {
|
|||
|
|
let depth = 1;
|
|||
|
|
let cur: BookmarkDTO | undefined = b;
|
|||
|
|
while (cur?.parentId && depth <= 100) {
|
|||
|
|
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId);
|
|||
|
|
if (!parent) break;
|
|||
|
|
depth += 1;
|
|||
|
|
cur = parent;
|
|||
|
|
}
|
|||
|
|
return depth;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openBookmarkComposer() {
|
|||
|
|
const pos = currentViewPosition();
|
|||
|
|
if (!pos) return;
|
|||
|
|
setBookmarkError(null);
|
|||
|
|
setBookmarkComposer({ ...pos, title: lastSelectionTextRef.current, note: "", parentId: null });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
|
|||
|
|
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้นเว้นว่าง = "หน้า N" */
|
|||
|
|
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
|
|||
|
|
const meaningful =
|
|||
|
|
a.type === "HIGHLIGHT" ||
|
|||
|
|
a.type === "TEXTBOX" ||
|
|||
|
|
a.type === "STAMP" ||
|
|||
|
|
a.type === "COMMENT";
|
|||
|
|
setBookmarkError(null);
|
|||
|
|
setBookmarkComposer({
|
|||
|
|
pageIndex: a.pageIndex,
|
|||
|
|
yOffset: clamp(yOffsetPct, 0, 100),
|
|||
|
|
title: meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "",
|
|||
|
|
note: "",
|
|||
|
|
parentId: null,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
|
|||
|
|
function currentViewPosition() {
|
|||
|
|
const el = pageWrapRefs.current[currentPage];
|
|||
|
|
if (!el) return null;
|
|||
|
|
const r = el.getBoundingClientRect();
|
|||
|
|
const centerY = window.scrollY + window.innerHeight / 2;
|
|||
|
|
const pageTop = r.top + window.scrollY;
|
|||
|
|
return {
|
|||
|
|
pageIndex: currentPage,
|
|||
|
|
yOffset: clamp(((centerY - pageTop) / r.height) * 100, 0, 100),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSaveBookmark() {
|
|||
|
|
const c = bookmarkComposer;
|
|||
|
|
if (!c || !user || bookmarkPendingRef.current) return;
|
|||
|
|
bookmarkPendingRef.current = true;
|
|||
|
|
try {
|
|||
|
|
const saved = await createBookmark({
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex: c.pageIndex,
|
|||
|
|
userId: user.userId,
|
|||
|
|
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
|
|||
|
|
yOffset: c.yOffset,
|
|||
|
|
parentId: c.parentId,
|
|||
|
|
note: c.note.trim() || null,
|
|||
|
|
});
|
|||
|
|
// ปิด popup เฉพาะเมื่อสำเร็จ — ถ้า fail (เช่น หัวข้อแม่ถูกคนอื่นลบไประหว่างกรอก)
|
|||
|
|
// คงข้อมูลที่พิมพ์ไว้ + แสดง error ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนหายเงียบ ๆ
|
|||
|
|
setBookmarkComposer(null);
|
|||
|
|
setBookmarkError(null);
|
|||
|
|
setBookmarks((prev) =>
|
|||
|
|
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
|||
|
|
);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("บันทึก bookmark ไม่สำเร็จ", err);
|
|||
|
|
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
|||
|
|
} finally {
|
|||
|
|
bookmarkPendingRef.current = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
|
|||
|
|
async function handleAddSubBookmark(parentId: string) {
|
|||
|
|
if (!user || bookmarkPendingRef.current) return;
|
|||
|
|
const pos = currentViewPosition();
|
|||
|
|
if (!pos) return;
|
|||
|
|
bookmarkPendingRef.current = true;
|
|||
|
|
try {
|
|||
|
|
const saved = await createBookmark({
|
|||
|
|
fileId: file.id,
|
|||
|
|
pageIndex: pos.pageIndex,
|
|||
|
|
userId: user.userId,
|
|||
|
|
title: `หน้า ${pos.pageIndex + 1}`,
|
|||
|
|
yOffset: pos.yOffset,
|
|||
|
|
parentId,
|
|||
|
|
});
|
|||
|
|
setBookmarks((prev) =>
|
|||
|
|
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
|||
|
|
);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("เพิ่ม sub-bookmark ไม่สำเร็จ", err);
|
|||
|
|
} finally {
|
|||
|
|
bookmarkPendingRef.current = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (✏ ใน sidebar, N2) */
|
|||
|
|
async function handleUpdateBookmark(
|
|||
|
|
id: string,
|
|||
|
|
patch: { title?: string; note?: string | null; parentId?: string | null },
|
|||
|
|
) {
|
|||
|
|
if (!user) return;
|
|||
|
|
try {
|
|||
|
|
const saved = await updateBookmark({ id, userId: user.userId, ...patch });
|
|||
|
|
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteBookmark(id: string) {
|
|||
|
|
if (!user) return;
|
|||
|
|
await startTransition(async () => {
|
|||
|
|
applyBookmark({ kind: "remove", id });
|
|||
|
|
try {
|
|||
|
|
await deleteBookmark(id, user.userId);
|
|||
|
|
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("ลบ bookmark ไม่สำเร็จ", err);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Text selection → highlight (PLAN.md §6.1) ─────────────────────────────
|
|||
|
|
|
|||
|
|
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
|
|||
|
|
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก */
|
|||
|
|
function readTextSelection(pageIndex: number) {
|
|||
|
|
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;
|
|||
|
|
const pageRect = pageEl.getBoundingClientRect();
|
|||
|
|
const rectsPct = clientRects.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 ต่อบรรทัดก่อนบันทึก
|
|||
|
|
// ตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4) — คลุมทั้งหน้า
|
|||
|
|
// ของเอกสารหนาแน่นแล้ว action ปฏิเสธ ทำให้ไฮไลต์หายเงียบ (text ใช้แค่ค้นหา/preview)
|
|||
|
|
text: selection.toString().slice(0, 5000),
|
|||
|
|
first: clientRects[0],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
|||
|
|
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (tool !== "highlight") {
|
|||
|
|
setLiveMark(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const onSelectionChange = () => {
|
|||
|
|
const selection = window.getSelection();
|
|||
|
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|||
|
|
setLiveMark(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const node = selection.getRangeAt(0).commonAncestorContainer;
|
|||
|
|
const el = node instanceof Element ? node : node.parentElement;
|
|||
|
|
const pageEl = el?.closest<HTMLElement>("[data-page]");
|
|||
|
|
if (!pageEl) {
|
|||
|
|
setLiveMark(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const pi = Number(pageEl.dataset.page);
|
|||
|
|
const sel = readTextSelection(pi);
|
|||
|
|
setLiveMark((prev) => {
|
|||
|
|
const next = sel ? { pageIndex: pi, rects: sel.rects } : null;
|
|||
|
|
// ค่าเดิมทุกอย่าง = คืน object เดิมให้ React ข้าม re-render (ลากทีละนิดไม่หนัก)
|
|||
|
|
const same =
|
|||
|
|
prev !== null &&
|
|||
|
|
next !== null &&
|
|||
|
|
prev.pageIndex === next.pageIndex &&
|
|||
|
|
prev.rects.length === next.rects.length &&
|
|||
|
|
prev.rects.every(
|
|||
|
|
(r, idx) =>
|
|||
|
|
r.x === next.rects[idx].x &&
|
|||
|
|
r.y === next.rects[idx].y &&
|
|||
|
|
r.width === next.rects[idx].width &&
|
|||
|
|
r.height === next.rects[idx].height,
|
|||
|
|
);
|
|||
|
|
return same ? prev : next;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
document.addEventListener("selectionchange", onSelectionChange);
|
|||
|
|
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
|||
|
|
}, [tool]);
|
|||
|
|
|
|||
|
|
function handleMouseUpOnPage(pageIndex: number) {
|
|||
|
|
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
|
|||
|
|
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
|
|||
|
|
lastSelectionTextRef.current = normalizeBookmarkTitle(
|
|||
|
|
readTextSelection(pageIndex)?.text ?? "",
|
|||
|
|
);
|
|||
|
|
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
|
|||
|
|
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
|
|||
|
|
if (tool !== "highlight") return;
|
|||
|
|
const sel = readTextSelection(pageIndex);
|
|||
|
|
if (!sel) return;
|
|||
|
|
setLiveMark(null);
|
|||
|
|
window.getSelection()?.removeAllRanges();
|
|||
|
|
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
|
|||
|
|
text: sel.text,
|
|||
|
|
color: highlightColor,
|
|||
|
|
rects: sel.rects,
|
|||
|
|
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
|
|||
|
|
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** N1: ชี้ mouse เหนือ annotation บนหน้า → เก็บ id ไว้แสดง tooltip (ใครทำ + โน้ต)
|
|||
|
|
* hit-test ชุดเดียวกับการคลิกเลือก (ตัวบนสุดชนะ) — เปลี่ยน state เฉพาะเมื่อ id เปลี่ยน
|
|||
|
|
* จึงไม่ re-render ทุก mousemove; ปิดช่วง tool ที่รับ pointer (วาด/ลบ), กดปุ่มค้าง
|
|||
|
|
* หรือตอน popover ของ comment pin เปิดอยู่ (กัน tooltip ลอยทับ popover) */
|
|||
|
|
function handlePageHoverMove(
|
|||
|
|
e: ReactMouseEvent<HTMLDivElement>,
|
|||
|
|
pageIndex: number,
|
|||
|
|
) {
|
|||
|
|
const frozen =
|
|||
|
|
tool === "draw" ||
|
|||
|
|
tool === "shape" ||
|
|||
|
|
tool === "erase" ||
|
|||
|
|
tool === "redact" ||
|
|||
|
|
tool === "pan";
|
|||
|
|
if (frozen || e.buttons !== 0 || openPinId !== null) {
|
|||
|
|
if (hoveredId !== null) setHoveredId(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const base = pageSizes[pageIndex];
|
|||
|
|
if (!base) return;
|
|||
|
|
const size = { width: base.width * scale, height: base.height * scale };
|
|||
|
|
const p = getPointerPercent(e, e.currentTarget);
|
|||
|
|
// เดินจากรายการหลังสุดก่อน (ตัวบนสุดชนะ) — for-loop ตรง ๆ ไม่สร้าง array ใหม่ต่อ mousemove
|
|||
|
|
let id: string | null = null;
|
|||
|
|
for (let k = visibleAnnotations.length - 1; k >= 0; k--) {
|
|||
|
|
const a = visibleAnnotations[k];
|
|||
|
|
if (a.pageIndex !== pageIndex || a.type === "COMMENT") continue; // pin มี popover ของตัวเองอยู่แล้ว
|
|||
|
|
// FIX-8: bbox pre-filter ก่อน hit-test แม่น — hitTestPath ของเส้น DRAW เป็น
|
|||
|
|
// O(points) ต่อเส้น ตัดรายการที่จุดอยู่ไกลทิ้งก่อนด้วย bbox + margin เผื่อ threshold
|
|||
|
|
// ของ hit-test (generic +4px, DRAW/SHAPE +6px — bbox ของเส้นขยายความหนาครึ่งหนึ่งแล้ว)
|
|||
|
|
const box = hoverBBoxPct.get(a.id);
|
|||
|
|
if (box) {
|
|||
|
|
const marginPx = 6;
|
|||
|
|
const mx = (marginPx / size.width) * 100;
|
|||
|
|
const my = (marginPx / size.height) * 100;
|
|||
|
|
if (
|
|||
|
|
p.x < box.x - mx ||
|
|||
|
|
p.x > box.x + box.width + mx ||
|
|||
|
|
p.y < box.y - my ||
|
|||
|
|
p.y > box.y + box.height + my
|
|||
|
|
) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (hitTestAnnotation(a, p, size)) {
|
|||
|
|
id = a.id;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setHoveredId((prev) => (prev === id ? prev : id));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handlePageClick(
|
|||
|
|
e: ReactMouseEvent<HTMLDivElement>,
|
|||
|
|
pageIndex: number,
|
|||
|
|
pageEl: HTMLElement,
|
|||
|
|
) {
|
|||
|
|
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
|
|||
|
|
if (selectedId !== null) {
|
|||
|
|
setSelectedId(null);
|
|||
|
|
setPreviewData(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
...(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,
|
|||
|
|
};
|
|||
|
|
// ความกว้างหน้าแรกที่ 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 highlight — ซ่อนสี selection น้ำเงินของเบราว์เซอร์ ให้เห็นแต่ preview มาร์กของเรา */}
|
|||
|
|
{tool === "highlight" && (
|
|||
|
|
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
|
|||
|
|
)}
|
|||
|
|
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
|
|||
|
|
z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */}
|
|||
|
|
{sidebarOpen && (
|
|||
|
|
<div
|
|||
|
|
className="fixed inset-0 z-[45] bg-slate-900/30 xl:hidden"
|
|||
|
|
aria-hidden
|
|||
|
|
onClick={() => setSidebarOpen(false)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{/* xl+ = sticky แยกคอลัมน์เหมือนเดิม / < xl = overlay เลื่อนเข้า-ออก
|
|||
|
|
(ปิดบน desktop ใช้ xl:hidden ออกจาก flow → main เต็มจอ) */}
|
|||
|
|
<aside
|
|||
|
|
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-xl:fixed max-xl:left-0 max-xl:z-50 max-xl:transition-transform max-xl:duration-200 ${
|
|||
|
|
sidebarOpen
|
|||
|
|
? "max-xl:translate-x-0"
|
|||
|
|
: "max-xl:pointer-events-none max-xl:-translate-x-full xl:hidden"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<AnnotationSidebar
|
|||
|
|
annotations={visibleAnnotations}
|
|||
|
|
bookmarks={optimisticBookmarks}
|
|||
|
|
fileId={file.id}
|
|||
|
|
userId={user?.userId ?? ""}
|
|||
|
|
userFilter={userFilter}
|
|||
|
|
users={users}
|
|||
|
|
onUserFilterChange={setUserFilter}
|
|||
|
|
replyCounts={replyCounts}
|
|||
|
|
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
|
|||
|
|
onUpdateAnnotation={(id, data) => {
|
|||
|
|
void handleUpdateAnnotation(id, data);
|
|||
|
|
}}
|
|||
|
|
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
|
|||
|
|
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
|
|||
|
|
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
|
|||
|
|
onGoToBookmark={goToBookmark}
|
|||
|
|
onGoToAnnotation={goToAnnotation}
|
|||
|
|
/>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
|
|||
|
|
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
|
|||
|
|
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
|
|||
|
|
<div className="touch-44 flex flex-wrap items-center gap-2 px-4 py-2">
|
|||
|
|
{/* UX review 2026-09-09: สลับ sidebar (ทุกขนาดจอ — desktop ซ่อนเพื่อดู PDF เต็มจอได้) */}
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title="สลับแผงข้าง"
|
|||
|
|
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
|
|||
|
|
aria-expanded={sidebarOpen}
|
|||
|
|
className="shrink-0 rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
|
|||
|
|
onClick={() => setSidebarOpen((o) => !o)}
|
|||
|
|
>
|
|||
|
|
<PanelLeft className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold">
|
|||
|
|
{file.title}
|
|||
|
|
</h1>
|
|||
|
|
|
|||
|
|
{/* ตัวเลื่อนหน้า < หน้า 1/12 > + ซูม −/%/+ — อยู่ด้วยกันกึ่งกลางแถว title (ซ้าย-ขวา flex-1 เท่ากัน) */}
|
|||
|
|
<div className="flex shrink-0 items-center gap-2">
|
|||
|
|
<div className="flex items-center rounded-lg border border-slate-300">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-label="หน้าก่อนหน้า"
|
|||
|
|
disabled={currentPage <= 0}
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
|||
|
|
onClick={() => goToPage(currentPage - 1)}
|
|||
|
|
>
|
|||
|
|
<ChevronLeft className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
<span className="w-16 text-center text-xs tabular-nums">
|
|||
|
|
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-label="หน้าถัดไป"
|
|||
|
|
disabled={currentPage >= pageSizes.length - 1}
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
|||
|
|
onClick={() => goToPage(currentPage + 1)}
|
|||
|
|
>
|
|||
|
|
<ChevronRight className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center rounded-lg border border-slate-300">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-label="ซูมออก"
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
|
|||
|
|
onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))}
|
|||
|
|
>
|
|||
|
|
<ZoomOut className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
<span className="w-12 text-center text-xs tabular-nums">
|
|||
|
|
{Math.round(scale * 100)}%
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-label="ซูมเข้า"
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
|
|||
|
|
onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))}
|
|||
|
|
>
|
|||
|
|
<ZoomIn className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-1 items-center justify-end gap-2">
|
|||
|
|
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ */}
|
|||
|
|
<Link
|
|||
|
|
href={`/annotate/${file.id}/summary`}
|
|||
|
|
title="หน้าสรุปหมายเหตุ"
|
|||
|
|
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
<ClipboardList className="h-4 w-4" />
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2">
|
|||
|
|
|
|||
|
|
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
|
|||
|
|
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
|
|||
|
|
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
|
|||
|
|
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */}
|
|||
|
|
<div className="flex flex-wrap rounded-lg border border-slate-300">
|
|||
|
|
{TOOLS.map(({ key, label, Icon }, i) => {
|
|||
|
|
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
|
|||
|
|
const activeCls = tool === key
|
|||
|
|
? "bg-primary text-white"
|
|||
|
|
: "text-slate-500 hover:bg-slate-100";
|
|||
|
|
|
|||
|
|
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
|
|||
|
|
if (key === "highlight") {
|
|||
|
|
const CurrentIcon =
|
|||
|
|
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon;
|
|||
|
|
return (
|
|||
|
|
<div key={key} ref={highlightMenuRef} className="relative">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title={label}
|
|||
|
|
aria-label={label}
|
|||
|
|
aria-haspopup="menu"
|
|||
|
|
aria-expanded={highlightMenuOpen}
|
|||
|
|
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
|
|||
|
|
onClick={(e) => {
|
|||
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|||
|
|
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
|
|||
|
|
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
|
|||
|
|
else {
|
|||
|
|
switchTool("highlight");
|
|||
|
|
setHighlightMenuOpen(true);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<CurrentIcon className="h-4 w-4" />
|
|||
|
|
<ChevronDown className="h-3 w-3" />
|
|||
|
|
</button>
|
|||
|
|
{highlightMenuOpen && (
|
|||
|
|
<div
|
|||
|
|
aria-label="ตั้งค่าการมาร์กข้อความ"
|
|||
|
|
className={`absolute ${menuFlip.highlight ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
|
|||
|
|
>
|
|||
|
|
{HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => (
|
|||
|
|
<button
|
|||
|
|
key={sKey}
|
|||
|
|
type="button"
|
|||
|
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
|||
|
|
highlightStyle === sKey
|
|||
|
|
? "bg-slate-100 text-slate-900"
|
|||
|
|
: "text-slate-600 hover:bg-slate-100"
|
|||
|
|
}`}
|
|||
|
|
onClick={() => setHighlightStyle(sKey)}
|
|||
|
|
>
|
|||
|
|
<SIcon className="h-3.5 w-3.5 shrink-0" />
|
|||
|
|
<span className="flex-1">{sLabel}</span>
|
|||
|
|
{highlightStyle === sKey && <Check className="h-3.5 w-3.5 shrink-0" />}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
<div className="my-1.5 border-t border-slate-100" />
|
|||
|
|
<PanelRow label="สี">
|
|||
|
|
<ColorSwatches value={highlightColor} onChange={setHighlightColor} />
|
|||
|
|
</PanelRow>
|
|||
|
|
<PanelRow label="ความทึบ">
|
|||
|
|
<NumberCombo
|
|||
|
|
value={highlightOpacityPct}
|
|||
|
|
options={[25, 50, 75, 100]}
|
|||
|
|
min={0}
|
|||
|
|
max={100}
|
|||
|
|
step={1}
|
|||
|
|
suffix="%"
|
|||
|
|
onChange={setHighlightOpacityPct}
|
|||
|
|
/>
|
|||
|
|
</PanelRow>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
|
|||
|
|
if (key === "draw") {
|
|||
|
|
const CurrentPenIcon = DRAW_PEN_ICONS[drawPen];
|
|||
|
|
return (
|
|||
|
|
<div key={key} ref={drawMenuRef} className="relative">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title={label}
|
|||
|
|
aria-label={label}
|
|||
|
|
aria-haspopup="menu"
|
|||
|
|
aria-expanded={drawMenuOpen}
|
|||
|
|
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
|
|||
|
|
onClick={(e) => {
|
|||
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|||
|
|
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
|
|||
|
|
if (tool === "draw") setDrawMenuOpen((o) => !o);
|
|||
|
|
else {
|
|||
|
|
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
|||
|
|
setDrawMenuOpen(true);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<CurrentPenIcon className="h-4 w-4" />
|
|||
|
|
<ChevronDown className="h-3 w-3" />
|
|||
|
|
</button>
|
|||
|
|
{drawMenuOpen && (
|
|||
|
|
<div
|
|||
|
|
aria-label="ตั้งค่าปากกา"
|
|||
|
|
className={`absolute ${menuFlip.draw ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
|
|||
|
|
>
|
|||
|
|
{(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => {
|
|||
|
|
const PenIcon = DRAW_PEN_ICONS[p];
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={p}
|
|||
|
|
type="button"
|
|||
|
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
|||
|
|
drawPen === p
|
|||
|
|
? "bg-slate-100 text-slate-900"
|
|||
|
|
: "text-slate-600 hover:bg-slate-100"
|
|||
|
|
}`}
|
|||
|
|
onClick={() => {
|
|||
|
|
setDrawPen(p);
|
|||
|
|
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น (เมนูเปิดค้างให้ปรับต่อ)
|
|||
|
|
setDrawColor(DRAW_PENS[p].color);
|
|||
|
|
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
|
|||
|
|
setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<PenIcon className="h-3.5 w-3.5 shrink-0" />
|
|||
|
|
<span className="flex-1">{DRAW_PENS[p].label}</span>
|
|||
|
|
{drawPen === p && <Check className="h-3.5 w-3.5 shrink-0" />}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
<div className="my-1.5 border-t border-slate-100" />
|
|||
|
|
<PanelRow label="สี">
|
|||
|
|
<ColorSwatches value={drawColor} onChange={setDrawColor} />
|
|||
|
|
</PanelRow>
|
|||
|
|
<PanelRow label="ความหนา">
|
|||
|
|
{/* แสดงเป็นหน่วยย่อ ×10 ของ % ความกว้างหน้า (4 = ปากกาเดิม 0.4%, 12 = ไฮไลต์เดิม 1.2%)
|
|||
|
|
— toFixed กัน float artifact ของการแปลง ÷10/×10 รอบกลับ */}
|
|||
|
|
<NumberCombo
|
|||
|
|
value={Number((drawWidthPct * 10).toFixed(2))}
|
|||
|
|
options={[1, 2, 4, 8, 12]}
|
|||
|
|
min={1}
|
|||
|
|
max={30}
|
|||
|
|
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}
|
|||
|
|
max={30}
|
|||
|
|
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)}
|
|||
|
|
>
|
|||
|
|
<Bold className="h-3.5 w-3.5" />
|
|||
|
|
</button>
|
|||
|
|
</PanelRow>
|
|||
|
|
<PanelRow label="จัดแนว">
|
|||
|
|
<div className="flex overflow-hidden rounded-md border border-slate-300">
|
|||
|
|
{(
|
|||
|
|
[
|
|||
|
|
["left", AlignLeft, "ชิดซ้าย"],
|
|||
|
|
["center", AlignCenter, "กึ่งกลาง"],
|
|||
|
|
["right", AlignRight, "ชิดขวา"],
|
|||
|
|
] as const
|
|||
|
|
).map(([a, AlignIcon, alignLabel]) => (
|
|||
|
|
<button
|
|||
|
|
key={a}
|
|||
|
|
type="button"
|
|||
|
|
aria-label={`จัดแนว${alignLabel}`}
|
|||
|
|
aria-pressed={textboxAlign === a}
|
|||
|
|
className={`px-2 py-1 transition ${
|
|||
|
|
textboxAlign === a
|
|||
|
|
? "bg-primary text-white"
|
|||
|
|
: "text-slate-500 hover:bg-slate-100"
|
|||
|
|
}`}
|
|||
|
|
onClick={() => setTextboxAlign(a)}
|
|||
|
|
>
|
|||
|
|
<AlignIcon className="h-3.5 w-3.5" />
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</PanelRow>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={key}
|
|||
|
|
type="button"
|
|||
|
|
title={label}
|
|||
|
|
aria-label={label}
|
|||
|
|
className={`px-2 py-1.5 transition ${corner} ${activeCls}`}
|
|||
|
|
onClick={() => switchTool(key)}
|
|||
|
|
>
|
|||
|
|
<Icon className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* F8: hint ความปลอดภัยของ redaction (PLAN-FEAT.md §5 — บนจอบังเท่านั้น) */}
|
|||
|
|
{tool === "redact" && (
|
|||
|
|
<p className="hidden max-w-xs text-[11px] leading-tight text-amber-600 lg:block">
|
|||
|
|
ปิดพรางบนจอเท่านั้น — ข้อความใต้กรอบยังเลือกได้ ต้องกด "Export PDF" เพื่อ
|
|||
|
|
ปิดพรางจริงในไฟล์ที่แจกจ่าย
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack */}
|
|||
|
|
<div className="flex items-center rounded-lg border border-slate-300">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title="Undo (Ctrl+Z)"
|
|||
|
|
aria-label="ย้อนการกระทำล่าสุด"
|
|||
|
|
disabled={!canUndo}
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
|||
|
|
onClick={() => void undoHistory()}
|
|||
|
|
>
|
|||
|
|
<Undo2 className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title="Redo (Ctrl+Shift+Z)"
|
|||
|
|
aria-label="ทำซ้ำการกระทำ"
|
|||
|
|
disabled={!canRedo}
|
|||
|
|
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
|||
|
|
onClick={() => void redoHistory()}
|
|||
|
|
>
|
|||
|
|
<Redo2 className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
|
|||
|
|
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
|
|||
|
|
<div className="relative ml-auto">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`}
|
|||
|
|
disabled={!user}
|
|||
|
|
onClick={openBookmarkComposer}
|
|||
|
|
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40"
|
|||
|
|
>
|
|||
|
|
<BookmarkPlus className="h-4 w-4" />
|
|||
|
|
</button>
|
|||
|
|
{bookmarkComposer && (
|
|||
|
|
<div
|
|||
|
|
className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
|
|||
|
|
onClick={(e) => e.stopPropagation()}
|
|||
|
|
>
|
|||
|
|
<p className="text-[11px] font-semibold text-slate-700">
|
|||
|
|
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
|
|||
|
|
</p>
|
|||
|
|
<input
|
|||
|
|
autoFocus
|
|||
|
|
value={bookmarkComposer.title}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setBookmarkComposer({ ...bookmarkComposer, title: e.target.value })
|
|||
|
|
}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัดของ viewer
|
|||
|
|
if (e.key === "Enter") void handleSaveBookmark();
|
|||
|
|
if (e.key === "Escape") setBookmarkComposer(null);
|
|||
|
|
}}
|
|||
|
|
placeholder={`ชื่อหัวข้อ (เว้นว่าง = "หน้า ${bookmarkComposer.pageIndex + 1}")`}
|
|||
|
|
className="mt-2 w-full rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|||
|
|
/>
|
|||
|
|
<textarea
|
|||
|
|
rows={2}
|
|||
|
|
value={bookmarkComposer.note}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setBookmarkComposer({ ...bookmarkComposer, note: e.target.value })
|
|||
|
|
}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (e.key === "Escape") setBookmarkComposer(null);
|
|||
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|||
|
|
void handleSaveBookmark();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
placeholder="โน้ต (ไม่บังคับ)"
|
|||
|
|
className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|||
|
|
/>
|
|||
|
|
<p className="mt-1.5 text-[11px] text-slate-400">วางที่</p>
|
|||
|
|
<select
|
|||
|
|
value={bookmarkComposer.parentId ?? ""}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setBookmarkComposer({
|
|||
|
|
...bookmarkComposer,
|
|||
|
|
parentId: e.target.value || null,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|||
|
|
className="mt-0.5 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|||
|
|
>
|
|||
|
|
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
|||
|
|
{optimisticBookmarks
|
|||
|
|
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
|
|||
|
|
.filter((b) => bookmarkDepthOf(b) < 3)
|
|||
|
|
.map((h) => (
|
|||
|
|
<option key={h.id} value={h.id}>
|
|||
|
|
ใต้ "{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
|
|||
|
|
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)}
|
|||
|
|
onMouseMove={(e) => 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 }
|
|||
|
|
: {}),
|
|||
|
|
}}
|
|||
|
|
annotations={visibleAnnotations
|
|||
|
|
.filter((a) => a.pageIndex === i)
|
|||
|
|
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
|
|||
|
|
.map((a) =>
|
|||
|
|
a.id === selectedId && previewData
|
|||
|
|
? { ...a, data: previewData }
|
|||
|
|
: a,
|
|||
|
|
)}
|
|||
|
|
replies={visibleReplies}
|
|||
|
|
openPinId={openPinId}
|
|||
|
|
ownUserId={user?.userId ?? ""}
|
|||
|
|
selectedId={selectedId}
|
|||
|
|
editingCommentId={editingCommentId}
|
|||
|
|
commentEditDraft={commentEditDraft}
|
|||
|
|
onCommentEditDraft={setCommentEditDraft}
|
|||
|
|
onStartCommentEdit={startCommentEdit}
|
|||
|
|
textbox={{
|
|||
|
|
composer: textboxComposer,
|
|||
|
|
commitComposer: commitTextboxComposer,
|
|||
|
|
cancelComposer: () => setTextboxComposer(null),
|
|||
|
|
editingId: editingTextboxId,
|
|||
|
|
startEdit: startTextboxEdit,
|
|||
|
|
commitEdit: commitTextboxEdit,
|
|||
|
|
}}
|
|||
|
|
onSelectAnnotation={handleSelectAnnotation}
|
|||
|
|
onPinToggle={handlePinToggle}
|
|||
|
|
onAnnotationPreview={setPreviewData}
|
|||
|
|
onAnnotationUpdate={(id, data) => {
|
|||
|
|
void handleUpdateAnnotation(id, data);
|
|||
|
|
}}
|
|||
|
|
onSetResolved={(id, resolved) => {
|
|||
|
|
void handleSetResolved(id, resolved);
|
|||
|
|
}}
|
|||
|
|
onAnnotationBookmark={handleAnnotationBookmark}
|
|||
|
|
onCreateReply={(annotationId, message) => {
|
|||
|
|
void handleCreateReply(annotationId, message);
|
|||
|
|
}}
|
|||
|
|
onDeleteReply={(id) => {
|
|||
|
|
void handleDeleteReply(id);
|
|||
|
|
}}
|
|||
|
|
onStrokeEnd={(points, widths) => {
|
|||
|
|
if (!user) return;
|
|||
|
|
void handleCreateAnnotation(i, "DRAW", {
|
|||
|
|
strokeColor: drawStyle.color,
|
|||
|
|
strokeWidth: drawStyle.strokeWidthPct,
|
|||
|
|
paths: points,
|
|||
|
|
opacity: drawStyle.opacity,
|
|||
|
|
...(widths ? { widths } : {}),
|
|||
|
|
...(drawPen === "highlighter" ? { pen: drawPen } : {}),
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
onShapeEnd={(data) => {
|
|||
|
|
if (!user) return;
|
|||
|
|
void handleCreateAnnotation(i, "SHAPE", data);
|
|||
|
|
}}
|
|||
|
|
onRedactionEnd={(data) => {
|
|||
|
|
if (!user) return;
|
|||
|
|
void handleCreateAnnotation(i, "REDACTION", data);
|
|||
|
|
}}
|
|||
|
|
onErase={(id) => void handleDeleteAnnotation(id)}
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
{/* N1: tooltip เมื่อ mouse ชี้ annotation — ใครทำ + โน้ตที่ฝากไว้
|
|||
|
|
(z-5 เหนือ pins, pointer-events none ไม่บังการคลิกใด ๆ) */}
|
|||
|
|
{(() => {
|
|||
|
|
const hovered = visibleAnnotations.find((a) => a.id === hoveredId);
|
|||
|
|
if (!hovered || hovered.pageIndex !== i) return null;
|
|||
|
|
const box = annotationBBox(hovered, { width: w, height: h });
|
|||
|
|
const note = annotationNote(hovered);
|
|||
|
|
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
|
|||
|
|
const flipLeft = box.x + box.width + 264 > w;
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="pointer-events-none absolute z-[5] w-max max-w-[256px] rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
|
|||
|
|
style={{
|
|||
|
|
...(flipLeft
|
|||
|
|
? { right: w - box.x + 8 }
|
|||
|
|
: { left: box.x + box.width + 8 }),
|
|||
|
|
top: Math.max(box.y - 4, 2),
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<p className="font-medium text-slate-700">
|
|||
|
|
{annotationTypeLabel(hovered)} <span className="text-slate-300">·</span>{" "}
|
|||
|
|
{hovered.userName}
|
|||
|
|
</p>
|
|||
|
|
{note && (
|
|||
|
|
<p className="mt-0.5 whitespace-pre-wrap break-words text-slate-500">
|
|||
|
|
{note}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
)}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{composer && (
|
|||
|
|
<div
|
|||
|
|
className="fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
|
|||
|
|
style={{
|
|||
|
|
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
|
|||
|
|
top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)),
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<textarea
|
|||
|
|
autoFocus
|
|||
|
|
rows={3}
|
|||
|
|
value={draft}
|
|||
|
|
onChange={(e) => setDraft(e.target.value)}
|
|||
|
|
placeholder="พิมพ์ข้อความ comment"
|
|||
|
|
className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Escape") setComposer(null);
|
|||
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|||
|
|
void handleComposerSave();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
<div className="mt-2 flex justify-end gap-2 text-xs">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
|
|||
|
|
onClick={() => setComposer(null)}
|
|||
|
|
>
|
|||
|
|
ยกเลิก
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={!draft.trim()}
|
|||
|
|
className="rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
|
|||
|
|
onClick={() => void handleComposerSave()}
|
|||
|
|
>
|
|||
|
|
บันทึก
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|