Add application codebase: PDF annotation for e-Court

Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 14:29:40 +07:00
parent 6fd0c3174f
commit eddcec28c7
60 changed files with 15055 additions and 0 deletions

View file

@ -0,0 +1,617 @@
"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Bold, Trash2 } from "lucide-react";
import type {
AnnotationData,
AnnotationDTO,
HighlightData,
HighlightStyle,
TextboxData,
} from "@/db/schema";
import {
annotationBBox,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
import { STAMP_COLOR } from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
// → ไม่บังการ select ข้อความของ textLayer (invariant #3)
// - ระหว่างลาก: ส่ง preview data ให้ parent ไป render ที่ layer เดิมของชนิดนั้น
// pointerup → commit ผ่าน onCommit (parent ทำ optimistic + updateAnnotation)
const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6;
type DragState = {
mode: "move" | "scale";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
anchorPct: Point;
};
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
function clampAxisScale(
rawS: number,
anchor: number,
edgeA: number,
edgeB: number,
origExtent: number,
): number {
let lo = 0;
let hi = 50;
for (const e of [edgeA, edgeB]) {
const denom = e - anchor;
if (Math.abs(denom) < EPS) continue;
const a = (0 - anchor) / denom;
const b = (100 - anchor) / denom;
lo = Math.max(lo, Math.min(a, b));
hi = Math.min(hi, Math.max(a, b));
}
if (origExtent > EPS) lo = Math.max(lo, MIN_BOX_PCT / origExtent);
if (lo > hi) return 1; // กรอบเดิมใหญ่เต็มหน้า — ไม่ขยาย
return clamp(rawS, lo, hi);
}
export default function AnnotationSelectionLayer({
annotation,
size,
editable,
pageBaseWidth,
disabled,
onPreview,
onCommit,
onDelete,
onBookmark,
}: {
annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
size: Size;
/** true เฉพาะ annotation ของตัวเอง — ของคนอื่นเห็นแค่กรอบ ไม่มี handle (guard จริงอยู่ที่ action) */
editable: boolean;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ตอนแสดงในแผง */
pageBaseWidth: number;
/** tool ที่ InkLayer ใช้ pointer (draw/shape/erase) → ปิด handle ไม่ให้ดัก pointer (PLAN-FEAT.md §5) */
disabled: boolean;
/** ระหว่างลาก: data ชั่วคราวให้ parent ไป render ที่ layer เดิม (null = เลิก preview) */
onPreview: (data: AnnotationData | null) => void;
/** pointerup แล้วมีการเปลี่ยนตำแหน่ง/ขนาดจริง → parent ทำ optimistic + updateAnnotation */
onCommit: (id: string, data: AnnotationData) => void;
/** ปุ่มลบบนกรอบ — parent เรียก deleteAnnotation (แสดงเฉพาะรายการของตัวเอง) */
onDelete: (id: string) => void;
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
onBookmark: (yOffsetPct: number) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
const boxPx = annotationBBox(annotation, size);
const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
const pointerPct = (e: ReactPointerEvent): Point => {
const el = rootRef.current;
if (!el) return { x: 0, y: 0 };
return getPointerPercent(e, el);
};
function beginDrag(
e: ReactPointerEvent<HTMLElement>,
mode: "move" | "scale",
anchorPct: Point,
) {
if (!editable || e.button !== 0) return;
e.preventDefault(); // กัน native text selection ระหว่างลาก
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = {
mode,
startPct: pointerPct(e),
origData: annotation.data,
origBoxPct: boxPct,
anchorPct,
};
movedRef.current = false;
latestRef.current = null;
}
function moveDrag(e: ReactPointerEvent<HTMLElement>) {
const st = dragRef.current;
if (!st) return;
e.stopPropagation();
const cur = pointerPct(e);
let next: AnnotationData;
if (st.mode === "move") {
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
const dx = clamp(
cur.x - st.startPct.x,
-st.origBoxPct.x,
100 - st.origBoxPct.x - st.origBoxPct.width,
);
const dy = clamp(
cur.y - st.startPct.y,
-st.origBoxPct.y,
100 - st.origBoxPct.y - st.origBoxPct.height,
);
next = translateAnnotationData(st.origData, dx, dy);
} else {
const sx = clampAxisScale(
(cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS),
st.anchorPct.x,
st.origBoxPct.x,
st.origBoxPct.x + st.origBoxPct.width,
st.origBoxPct.width,
);
const sy = clampAxisScale(
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS),
st.anchorPct.y,
st.origBoxPct.y,
st.origBoxPct.y + st.origBoxPct.height,
st.origBoxPct.height,
);
next = scaleAnnotationData(st.origData, st.anchorPct, sx, sy);
}
movedRef.current = true;
latestRef.current = next;
onPreview(next);
}
function endDrag(e: ReactPointerEvent<HTMLElement>) {
const st = dragRef.current;
if (!st) return;
e.stopPropagation();
dragRef.current = null;
onPreview(null);
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
movedRef.current = false;
latestRef.current = null;
}
function cancelDrag() {
if (!dragRef.current) return;
dragRef.current = null;
movedRef.current = false;
latestRef.current = null;
onPreview(null);
}
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
function commitColor(color: string) {
const d = annotation.data;
if ("rects" in d) onCommit(annotation.id, { ...d, color });
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
else if ("label" in d) onCommit(annotation.id, { ...d, color });
}
function commitStrokeWidth(w: number) {
const d = annotation.data;
if ("paths" in d) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
onCommit(annotation.id, {
...d,
strokeWidth: w,
...(d.widths
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
: {}),
});
} else if ("shape" in d) {
onCommit(annotation.id, { ...d, strokeWidth: w });
}
}
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) {
const d = annotation.data;
if (!("rects" in d)) return;
onCommit(annotation.id, { ...d, style });
}
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
function commitHighlightOpacity(opacityPct: number) {
const d = annotation.data;
if (!("rects" in d)) return;
const { opacity: _prev, ...rest } = d;
onCommit(annotation.id, {
...rest,
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
});
}
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) {
const d = annotation.data;
if (!("label" in d)) return;
onCommit(annotation.id, { ...d, scale });
}
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
function commitTextboxPatch(
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) {
if (!textboxData) return;
onCommit(annotation.id, { ...textboxData, ...patch });
}
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
// paths/shape = เส้น/รูปทรง, label = STAMP) — เก็บเป็นตัวแปรท้องถิ่นเพื่อให้ TS
// narrow type คงอยู่ใน callback ของ JSX
// HIGHLIGHT ห้ามย้าย/ขยาย — boolean แยกไว้ใช้กับเงื่อนไข handle (ไม่แตะการ narrow ของตัวแปรอื่น)
const isHighlightMark = "rects" in annotation.data;
const highlightData: HighlightData | null =
"rects" in annotation.data ? annotation.data : null;
const strokeData =
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
const stampData = "label" in annotation.data ? annotation.data : null;
const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null;
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{
key: "nw",
x: boxPx.x,
y: boxPx.y,
cursor: "nwse-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
},
{
key: "ne",
x: boxPx.x + boxPx.width,
y: boxPx.y,
cursor: "nesw-resize",
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
},
{
key: "sw",
x: boxPx.x,
y: boxPx.y + boxPx.height,
cursor: "nesw-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
},
{
key: "se",
x: boxPx.x + boxPx.width,
y: boxPx.y + boxPx.height,
cursor: "nwse-resize",
anchor: { x: boxPct.x, y: boxPct.y },
},
];
const toolbarAbove = boxPx.y >= 36;
// แผงเครื่องมือปกติเปิดขึ้นด้านบน bbox — แต่ถ้ารายการอยู่ใกล้บนสุดของ viewport
// (หน้าเลื่อนขึ้นจนกรอบอยู่ใต้ sticky header) แผงจะเปิดขึ้นไปโดน header บัง
// → วัดตำแหน่งจริงหลัง render แล้วพลิกลงด้านล่างแทน (รีเซ็ตทุกครั้งที่เปลี่ยนรายการ)
const panelRef = useRef<HTMLDivElement | null>(null);
const [flipBelow, setFlipBelow] = useState(false);
useEffect(() => {
setFlipBelow(false);
}, [annotation.id]);
useEffect(() => {
if (!toolbarAbove || flipBelow || !panelRef.current) return;
const headerH = document.querySelector("header")?.offsetHeight ?? 0;
if (panelRef.current.getBoundingClientRect().top < headerH) {
setFlipBelow(true);
}
}, [toolbarAbove, flipBelow, annotation.id, boxPx.x, boxPx.y]);
const openAbove = toolbarAbove && !flipBelow;
return (
<div
ref={rootRef}
className="absolute inset-0 z-[4] select-none"
style={{ pointerEvents: "none" }}
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
>
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
<div
className="absolute border-2 border-dashed border-primary"
style={{
left: boxPx.x,
top: boxPx.y,
width: boxPx.width,
height: boxPx.height,
}}
/>
{/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
{editable && !disabled && !isHighlightMark && (
<>
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */}
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
{corners.map((c) => (
<div
key={c.key}
className="absolute rounded-sm border border-primary bg-primary/50"
style={{
left: c.x - HANDLE_PX / 2,
top: c.y - HANDLE_PX / 2,
width: HANDLE_PX,
height: HANDLE_PX,
cursor: c.cursor,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
))}
</>
)}
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */}
{editable && !disabled && (
<div
ref={panelRef}
className="absolute w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
left: boxPx.x,
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
transform: openAbove ? "translateY(-100%)" : undefined,
pointerEvents: "auto",
}}
>
{highlightData && (
<>
<PanelRow label="สี">
<ColorSwatches value={highlightData.color} onChange={commitColor} />
</PanelRow>
<PanelRow label="รูปแบบ">
<div className="flex flex-1 gap-1">
{HIGHLIGHT_STYLE_BTNS.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
aria-label={label}
title={label}
aria-pressed={(highlightData.style ?? "highlight") === key}
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${
(highlightData.style ?? "highlight") === key
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitHighlightStyle(key)}
>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round((highlightData.opacity ?? 1) * 100)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={commitHighlightOpacity}
/>
</PanelRow>
</>
)}
{strokeData && (
<PanelRow label="สี">
<ColorSwatches value={strokeData.strokeColor} onChange={commitColor} />
</PanelRow>
)}
{strokeData && (
<PanelRow label="ความหนา">
{/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับ dropdown ของ tool draw/shape */}
<NumberCombo
value={Number((strokeData.strokeWidth * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
max={30}
step={1}
onChange={(v) => commitStrokeWidth(v / 10)}
/>
</PanelRow>
)}
{stampData && (
<>
<PanelRow label="สี">
<ColorSwatches
value={stampData.color ?? STAMP_COLOR}
onChange={commitColor}
/>
</PanelRow>
<PanelRow label="ขนาด">
<NumberCombo
value={stampData.scale}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={commitStampScale}
/>
</PanelRow>
</>
)}
{textboxData && (
<>
<PanelRow label="สี">
<ColorSwatches
value={textboxData.color}
onChange={(c) => commitTextboxPatch({ color: c })}
/>
</PanelRow>
<PanelRow label="ขนาด">
{/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง — เก็บใน DB เป็น % ของความกว้างหน้า */}
<NumberCombo
value={Number(((textboxData.fontSize / 100) * pageBaseWidth).toFixed(1))}
options={[10, 12, 14, 18, 24]}
min={6}
max={48}
step={1}
suffix="px"
onChange={(v) => commitTextboxPatch({ fontSize: (v / pageBaseWidth) * 100 })}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round((textboxData.opacity ?? 1) * 100)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={(v) => commitTextboxPatch({ opacity: v / 100 })}
/>
</PanelRow>
<PanelRow label="ตัวหนา">
<button
type="button"
aria-label="สลับตัวหนาทั้งกล่อง"
aria-pressed={!!textboxData.bold}
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${
textboxData.bold
? "border-primary bg-primary text-white"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
>
<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, aLabel]) => (
<button
key={a}
type="button"
aria-label={`จัดแนว${aLabel}`}
aria-pressed={(textboxData.align ?? "left") === a}
className={`px-2 py-1 transition ${
(textboxData.align ?? "left") === a
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ align: a })}
>
<AlignIcon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
</>
)}
<div className="my-1 border-t border-slate-100" />
<div className="flex items-center justify-between">
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
<button
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
}

581
components/pdf/InkLayer.tsx Normal file
View file

@ -0,0 +1,581 @@
"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { RedactionData, ShapeData, ShapeKind } from "@/db/schema";
import {
clamp,
pathToPercent,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { hitTestPath, hitTestShape } from "@/utils/geometry";
/** เส้นวาดที่เก็บใน DB — จุดทุกจุดเป็น % ของหน้า, strokeWidth เป็น % ของความกว้าง */
export type InkStroke = {
id: string;
/** เจ้าของ — eraser ใช้กรอง hit-test เฉพาะของตัวเอง (F6) */
userId: string;
color: string;
strokeWidthPct: number;
points: Point[];
/** F7/E1: ความหนาต่อจุด (% ของความกว้างหน้า) — มีเฉพาะเส้นที่วาดด้วยสไตลัส */
widthsPct?: number[];
/** ความทึบของเส้น (0–1) — parent คำนวณให้แล้วรวม fallback ตามชนิดปากกา (effectiveDrawOpacity) */
opacity?: number;
};
/** รูปทรงที่เก็บใน DB (F5/B1) — พิกัด % ทั้งหมด */
export type InkShape = { id: string; userId: string; data: ShapeData };
/** ปิดพรางที่เก็บใน DB (F8/B4) — rect % ของหน้า */
export type InkRedaction = { id: string; userId: string; data: RedactionData };
/** รูปทรงที่กำลังลาก (px) */
type LiveShape = { kind: ShapeKind; start: Point; end: Point };
/**
* F7/E1: แปลง e.pressure (0–1, mouse ได้ 0.5 ตลอด) เป็นตัวคูณความหนา
* 0.5 + pressure → เมาส์/แตะนิ้วได้ 1.0 เท่าเดิมพอดี, ปากกากดเบา–หนักได้ 0.5–1.5 เท่า
*/
const pressureFactor = (pressure: number): number => 0.5 + clamp(pressure, 0, 1);
/**
* วาด "ตรง ๆ ด้วยมือ" แล้วปล่อย → snap เป็นเส้นตรงอัตโนมัติ (ไม่ต้องกด Shift):
* จุดทุกจุดต้องห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px)
* เส้นโค้ง/ขดจริง ๆ จะหลุดเกณฑ์ → เหลือเป็น freehand ตามที่วาด
*/
function isNearlyStraight(pts: Point[]): boolean {
const a = pts[0];
const b = pts[pts.length - 1];
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 24) return false; // เส้นสั้นมาก (ขีดสั้น ๆ) ปล่อยตามที่วาด
const maxDev = Math.max(len * 0.05, 3);
for (const p of pts) {
// ระยะตั้งฉากจาก p ไปยังเส้นตรง a→b
const dev = Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len;
if (dev > maxDev) return false;
}
return true;
}
/**
* SVG freehand (PLAN.md §6.2) + รูปทรง (PLAN-FEAT.md §3 F5) — ห้ามใส่ viewBox
* (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) render ระบบ px จริง
* แปลง % → px ด้วยขนาดที่ ResizeObserver วัด
*/
export default function InkLayer({
size,
strokes,
shapes,
redactions,
active,
nextStroke,
shapeTool,
eraseMode,
redactMode,
ownUserId,
onStrokeEnd,
onShapeEnd,
onRedactionEnd,
onErase,
}: {
/** ขนาด px ตามคำนวณ (ใช้เป็นค่าเริ่มต้นก่อน ResizeObserver วัดได้) */
size: Size;
/** เส้นเดิมจาก DB (พิกัด %) */
strokes: InkStroke[];
/** รูปทรงเดิมจาก DB (พิกัด %) */
shapes: InkShape[];
/** ปิดพรางเดิมจาก DB (พิกัด %) — วาดทับบังข้อความจริงบนจอ (F8) */
redactions: InkRedaction[];
/** tool วาด/รูปทรง/eraser/redact → รับ pointer events */
active: boolean;
/** ค่าเริ่มต้นของเส้นใหม่ที่กำลังวาด (tool draw) — opacity 0–1 ใช้ตอน render */
nextStroke: { color: string; strokeWidthPct: number; opacity?: number };
/** tool shape → ชนิดรูปทรงที่จะวาด (null = โหมดวาดเส้น) */
shapeTool: { kind: ShapeKind; color: string; strokeWidthPct: number } | null;
/** tool erase → ลากผ่านเส้น/รูปทรงของตัวเองเพื่อลบ (F6/B3) */
eraseMode: boolean;
/** tool redact → ลากคลุมพื้นที่เพื่อปิดพราง (F8/B4) */
redactMode: boolean;
/** userId ของผู้ใช้ปัจจุบัน — hit-test เฉพาะรายการของตัวเอง */
ownUserId: string;
/** pointerup แล้วจุด ≥ 2 → ส่งจุด (% ของหน้า) + widths (%, เฉพาะปากกา) ให้ parent บันทึก */
onStrokeEnd?: (pointsPct: Point[], widthsPct?: number[]) => void;
/** pointerup แล้วรูปทรงมีขนาดพอ → ส่ง ShapeData (% ของหน้า) ให้ parent บันทึก */
onShapeEnd?: (data: ShapeData) => void;
/** pointerup แล้วพื้นที่พอ → ส่ง RedactionData (% ของหน้า) ให้ parent บันทึก */
onRedactionEnd?: (data: RedactionData) => void;
/** hit-test โดนรายการของตัวเอง → parent เรียก deleteAnnotation ทันที */
onErase?: (id: string) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [measured, setMeasured] = useState<Size>(size);
const [livePoints, setLivePoints] = useState<Point[]>([]);
const [liveShape, setLiveShape] = useState<LiveShape | null>(null);
// Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้)
const [shiftDown, setShiftDown] = useState(false);
const drawingRef = useRef(false);
// source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater
const pointsRef = useRef<Point[]>([]);
// F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา
const factorsRef = useRef<number[] | null>(null);
const [liveFactors, setLiveFactors] = useState<number[] | null>(null);
// F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ)
const sawPenRef = useRef(false);
// เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย)
const shapeRef = useRef<LiveShape | null>(null);
// FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม
const liveRafRef = useRef<number | null>(null);
// โหมด eraser กำลังกดค้าง (F6)
const erasingRef = useRef(false);
// พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ
const redactRef = useRef<{ start: Point; end: Point } | null>(null);
const [liveRedaction, setLiveRedaction] = useState<Rect | null>(null);
// re-render เมื่อ resize/zoom (rules/coordinates.md)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setMeasured({ width: el.clientWidth, height: el.clientHeight });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// FIX-10: mirror prop size ทันทีเมื่อ zoom — ไม่งั้นมี 1 render
// หลัง zoom ที่เส้นทั้งหน้าวาดด้วย scale เก่าก่อน ResizeObserver ยิงมายืนยันค่าจริง
// เทียบค่าก่อน set — prop เป็น inline object ใหม่ทุก parent render แต่ค่าเท่าเดิม
// ต้องไม่นับเป็นการเปลี่ยน state (กัน render ซ้ำเปลือง ๆ)
useEffect(() => {
setMeasured((prev) =>
prev.width === size.width && prev.height === size.height ? prev : size,
);
}, [size]);
// FIX-8: เลิกวาดกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย
useEffect(
() => () => {
if (liveRafRef.current !== null) cancelAnimationFrame(liveRafRef.current);
},
[],
);
// ติดตาม Shift ระดับ window — กด/ปล่อยกลางทางระหว่างลากก็ต้อง snap เป็นเส้นตรง/คลายกลับทันที
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(true);
};
const up = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(false);
};
const clear = () => setShiftDown(false); // window blur ระหว่างกดค้าง
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", clear);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", clear);
};
}, []);
// clamp ให้อยู่ในหน้าเสมอ — pointer capture ส่งพิกัดนอก page ได้ และ % ติดลบ
// จะโดน Server Action ปฏิเสธทั้งเส้น (rules/coordinates.md: clamp 0–100)
const toLocalPx = (e: { clientX: number; clientY: number }): Point => {
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
const el = rootRef.current;
if (!el) return { x: 0, y: 0 };
const r = el.getBoundingClientRect();
return {
x: clamp(e.clientX - r.left, 0, r.width),
y: clamp(e.clientY - r.top, 0, r.height),
};
};
/** normalize จุดเริ่ม/จุดปลายของการลากเป็น bbox (px) — ใช้กับ redaction (F8) */
const bboxFromDrag = (d: { start: Point; end: Point }): Rect => ({
x: Math.min(d.start.x, d.end.x),
y: Math.min(d.start.y, d.end.y),
width: Math.abs(d.end.x - d.start.x),
height: Math.abs(d.end.y - d.start.y),
});
function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>) {
if (!active || e.button !== 0) return;
// F7/E1 palm rejection: เคยเห็นปากกาในหน้านี้ → ละเว้นนิ้ว (แตะ/ฝ่ามือ)
if (e.pointerType === "pen") {
sawPenRef.current = true;
} else if (e.pointerType === "touch" && sawPenRef.current) {
return;
}
// pointer capture → เส้นไม่ขาดเวลาเลื่อนออกนอก page (PLAN.md §6.2)
e.currentTarget.setPointerCapture(e.pointerId);
if (eraseMode) {
erasingRef.current = true;
eraseAt(e);
return;
}
if (redactMode) {
const p = toLocalPx(e);
redactRef.current = { start: p, end: p };
setLiveRedaction({ x: p.x, y: p.y, width: 0, height: 0 });
return;
}
drawingRef.current = true;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = { kind: shapeTool.kind, start: p, end: p };
setLiveShape(shapeRef.current);
} else {
// FIX-8: state ต้องไม่แชร์ reference กับ ref (ref ถูก mutate push ระหว่างวาด)
pointsRef.current = [p];
setLivePoints([p]);
// ความหนาต่อจุดเก็บเฉพาะปากกา (mouse/touch ไม่มี pressure จริง → สเปกเดิม)
if (e.pointerType === "pen") {
factorsRef.current = [pressureFactor(e.pressure)];
} else {
factorsRef.current = null;
}
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
}
}
function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (erasingRef.current) {
eraseAt(e);
return;
}
if (redactRef.current) {
redactRef.current = { ...redactRef.current, end: toLocalPx(e) };
setLiveRedaction(bboxFromDrag(redactRef.current));
return;
}
if (!drawingRef.current) return;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = shapeRef.current
? { ...shapeRef.current, end: p }
: null;
setLiveShape(shapeRef.current);
} else {
// FIX-8: push จุดลง ref ทันที (ข้อมูลที่บันทึกครบทุกจุดเหมือนเดิม) แต่ยิง
// re-render ครั้งเดียวต่อ frame ผ่าน rAF — กัน copy array + re-render SVG
// ทั้งเส้นใหม่ทุก pointermove (= O(n²) เมื่อเส้นยาวหลายพันจุด)
pointsRef.current.push(p);
if (factorsRef.current) {
factorsRef.current.push(pressureFactor(e.pressure));
}
if (liveRafRef.current === null) {
liveRafRef.current = requestAnimationFrame(() => {
liveRafRef.current = null;
setLivePoints([...pointsRef.current]);
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
});
}
}
}
/** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */
function eraseAt(e: ReactPointerEvent<HTMLDivElement>) {
if (!onErase || ownUserId === "") return;
const p = pointToPercent(toLocalPx(e), measured);
const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px)
for (const s of strokes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.strokeWidthPct, measured.width) / 2 + pad;
if (hitTestPath(s.points, p, threshold, measured)) {
onErase(s.id);
return;
}
}
for (const s of shapes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.data.strokeWidth, measured.width) / 2 + pad;
if (hitTestShape(s.data, p, threshold, measured)) {
onErase(s.id);
return;
}
}
}
function endDrag() {
if (erasingRef.current) {
erasingRef.current = false;
return;
}
if (redactRef.current) {
const bbox = bboxFromDrag(redactRef.current);
redactRef.current = null;
setLiveRedaction(null);
// เล็กเกิน (คลิกเฉย ๆ) ไม่สร้าง
if (bbox.width >= 2 && bbox.height >= 2) {
onRedactionEnd?.({ rect: rectToPercent(bbox, measured) });
}
return;
}
if (!drawingRef.current) return;
drawingRef.current = false;
if (shapeTool) {
const st = shapeRef.current;
shapeRef.current = null;
setLiveShape(null);
if (st) endShape(st);
} else {
// FIX-8: ยกเลิก rAF ที่ยังค้างอยู่ก่อนเคลียร์ live state — ไม่งั้น callback
// ยิงหลังจากนี้แล้วเส้นที่วาดจบโผล่กลับมาอีกครั้งบนหน้าจอ
if (liveRafRef.current !== null) {
cancelAnimationFrame(liveRafRef.current);
liveRafRef.current = null;
}
const points = pointsRef.current;
const factors = factorsRef.current;
pointsRef.current = [];
factorsRef.current = null;
setLivePoints([]);
setLiveFactors(null);
if (points.length >= 2) {
// ปล่อยแล้ว snap เป็นเส้นตรง: Shift ค้าง = บังคับตรงเสมอ หรือวาดตรง ๆ ด้วยมือ
// (isNearlyStraight) → เหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform)
const snapped = straight || isNearlyStraight(points);
const finalPoints = snapped ? [points[0], points[points.length - 1]] : points;
const widthsPct =
factors && !snapped
? factors.map((f) => nextStroke.strokeWidthPct * f)
: undefined;
onStrokeEnd?.(pathToPercent(finalPoints, measured), widthsPct);
}
}
}
/** คำนวณ ShapeData (%) จากรูปทรงที่ลากจบ — เล็กเกินไป (คลิกเฉย ๆ) ไม่สร้าง */
function endShape(st: LiveShape) {
const tool = shapeTool;
if (!tool) return;
const { start, end } = st;
if (st.kind === "rect" || st.kind === "ellipse") {
const w = Math.abs(end.x - start.x);
const h = Math.abs(end.y - start.y);
if (w < 2 || h < 2) return;
const rect = rectToPercent(
{
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
width: w,
height: h,
},
measured,
);
onShapeEnd?.({
shape: st.kind,
rect,
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
} else {
if (Math.hypot(end.x - start.x, end.y - start.y) < 2) return;
onShapeEnd?.({
shape: st.kind,
from: pointToPercent(start, measured),
to: pointToPercent(end, measured),
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
}
}
const toPathD = (points: Point[]): string =>
points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
/** หัวลูกศร — path สามเหลี่ยมคำนวณจาก from→to (F5 สเปก render) */
const arrowHeadPath = (from: Point, to: Point, sw: number): string => {
const head = Math.max(sw * 3.5, 8);
const ang = Math.atan2(to.y - from.y, to.x - from.x);
const spread = Math.PI / 7;
const p1 = {
x: to.x - head * Math.cos(ang - spread),
y: to.y - head * Math.sin(ang - spread),
};
const p2 = {
x: to.x - head * Math.cos(ang + spread),
y: to.y - head * Math.sin(ang + spread),
};
return `M${to.x} ${to.y} L${p1.x} ${p1.y} L${p2.x} ${p2.y} Z`;
};
const strokeProps = (color: string, sw: number, opacity?: number) => ({
stroke: color,
strokeWidth: sw,
// opacity < 1 = เส้นโปร่งแสงให้เห็นข้อความใต้เส้น (ค่ารวม fallback ตาม pen มาจาก parent แล้ว)
strokeOpacity: opacity ?? 1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
fill: "none",
});
/** F7/E1: มี widths ต่อจุด → render เป็น segment ทีละท่อน (ความหนาต่างกัน), ไม่มี → path เดียวเหมือนเดิม */
function renderStrokeEl(
key: string,
color: string,
baseSwPct: number,
ptsPx: Point[],
widthsPct?: number[],
opacity?: number,
) {
if (widthsPct && widthsPct.length === ptsPx.length && ptsPx.length >= 2) {
return (
<g key={key}>
{ptsPx.slice(0, -1).map((p, i) => {
const q = ptsPx[i + 1];
const w =
(toPx(widthsPct[i], measured.width) +
toPx(widthsPct[i + 1], measured.width)) /
2;
return (
<line key={i} x1={p.x} y1={p.y} x2={q.x} y2={q.y} {...strokeProps(color, w, opacity)} />
);
})}
</g>
);
}
return (
<path
key={key}
d={toPathD(ptsPx)}
{...strokeProps(color, toPx(baseSwPct, measured.width), opacity)}
/>
);
}
/** render รูปทรงหนึ่งชิ้นจาก ShapeData (%) — ใช้ได้ทั้งของเดิมและ live preview */
function renderShapeEl(d: ShapeData, key?: string) {
const sw = toPx(d.strokeWidth, measured.width);
if ("rect" in d) {
// rect/ellipse เก็บเป็น bbox ใน data.rect
const r = rectToPx(d.rect, measured);
if (d.shape === "ellipse") {
return (
<ellipse
key={key}
cx={r.x + r.width / 2}
cy={r.y + r.height / 2}
rx={r.width / 2}
ry={r.height / 2}
{...strokeProps(d.strokeColor, sw)}
/>
);
}
return <rect key={key} x={r.x} y={r.y} width={r.width} height={r.height} {...strokeProps(d.strokeColor, sw)} />;
}
// line/arrow — จุดเริ่ม/จุดปลาย (D3)
const a = pointToPx(d.from, measured);
const b = pointToPx(d.to, measured);
const line = <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} {...strokeProps(d.strokeColor, sw)} />;
if (d.shape === "line") return <g key={key}>{line}</g>;
return (
<g key={key}>
{line}
<path d={arrowHeadPath(a, b, sw)} fill={d.strokeColor} />
</g>
);
}
// เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact)
const straight = shiftDown && !shapeTool && !eraseMode && !redactMode;
return (
<div
ref={rootRef}
className="absolute inset-0 z-[3] touch-none"
style={{ pointerEvents: active ? "auto" : "none" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */}
<svg width={measured.width} height={measured.height} className="block">
{strokes.map((s) =>
renderStrokeEl(
s.id,
s.color,
s.strokeWidthPct,
s.points.map((p) => pointToPx(p, measured)),
s.widthsPct,
s.opacity,
),
)}
{shapes.map((s) => renderShapeEl(s.data, s.id))}
{/* REDACTION — ดำทึบ บังข้อความจริงบนจอ (F8/B4) */}
{redactions.map((rd) => {
const r = rectToPx(rd.data.rect, measured);
return (
<rect key={rd.id} x={r.x} y={r.y} width={r.width} height={r.height} fill="#000" />
);
})}
{liveRedaction && (
<rect
x={liveRedaction.x}
y={liveRedaction.y}
width={liveRedaction.width}
height={liveRedaction.height}
fill="#000"
/>
)}
{livePoints.length >= 2 &&
renderStrokeEl(
"live-stroke",
nextStroke.color,
nextStroke.strokeWidthPct,
straight
? [livePoints[0], livePoints[livePoints.length - 1]]
: livePoints,
liveFactors && !straight
? liveFactors.map((f) => nextStroke.strokeWidthPct * f)
: undefined,
nextStroke.opacity,
)}
{liveShape && shapeTool &&
renderShapeEl(
liveShape.kind === "rect" || liveShape.kind === "ellipse"
? {
shape: liveShape.kind,
rect: rectToPercent(
{
x: Math.min(liveShape.start.x, liveShape.end.x),
y: Math.min(liveShape.start.y, liveShape.end.y),
width: Math.abs(liveShape.end.x - liveShape.start.x),
height: Math.abs(liveShape.end.y - liveShape.start.y),
},
measured,
),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
}
: {
shape: liveShape.kind,
from: pointToPercent(liveShape.start, measured),
to: pointToPercent(liveShape.end, measured),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
},
)}
</svg>
</div>
);
}

2918
components/pdf/PDFViewer.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, STAMP_FONT_RATIO } from "@/utils/annotation-display";
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
// (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool)
// - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง
// = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13)
// - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า
/** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */
export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData };
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */
function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } {
const segs: TextboxSegment[] = [];
const push = (text: string, bold: boolean) => {
if (!text) return;
const last = segs[segs.length - 1];
if (last && !!last.bold === bold) last.text += text;
else segs.push({ text, ...(bold ? { bold: true } : {}) });
};
const walk = (node: Node, bold: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
push(node.nodeValue ?? "", bold);
} else if (node.nodeName === "BR") {
push("\n", bold);
} else {
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
const kids = Array.from(node.childNodes);
kids.forEach((c, i) => {
walk(c, b);
// DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่
if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b);
});
}
};
walk(root, false);
return { text: segs.map((s) => s.text).join(""), segments: segs };
}
/** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B
* ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */
function TextboxEditor({
size,
position,
fontSizePct,
color,
bold,
opacity,
align,
enabled,
initialText,
initialSegments,
onSave,
onCancel,
}: {
size: Size;
position: Point;
fontSizePct: number;
/** สีตัวอักษร (จากตัวตั้งค่าของ tool / สีของกล่องที่แก้) */
color: string;
bold?: boolean;
opacity?: number;
align?: "left" | "center" | "right";
/** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */
enabled: boolean;
initialText: string;
initialSegments?: TextboxSegment[];
onSave: (text: string, segments: TextboxSegment[]) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
// uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
const initialHtml = useMemo(
() => ({ __html: segmentsToHtml(initialText, initialSegments) }),
[initialText, initialSegments],
);
// focus + วาง caret ท้ายข้อความตอนเปิด editor
useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}, []);
/** คลิกที่อื่น/สลับ tool (blur) = บันทึกอัตโนมัติ — ข้อความว่าง = ปิดอย่างเดียวไม่สร้าง */
const submit = () => {
const el = ref.current;
if (!el) return;
const { text, segments } = serializeEditor(el);
if (text.trim()) onSave(text, segments);
else onCancel();
};
return (
<div
className="absolute z-10"
style={{
left: toPx(position.x, size.width),
top: toPx(position.y, size.height),
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
minWidth: "1em",
opacity: opacity ?? 1,
pointerEvents: enabled ? "auto" : "none",
}}
onClick={(e) => e.stopPropagation()}
>
<div
ref={ref}
role="textbox"
aria-multiline="true"
aria-label="แก้ไขข้อความ"
contentEditable
suppressContentEditableWarning
spellCheck={false}
onBlur={submit}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
// FIX-12: document.execCommand ถูก mark deprecated แต่
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
// (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault();
document.execCommand("bold");
}
}}
onPaste={(e) => {
// วางเป็น plain text กัน HTML แปลกปลอมเข้า contentEditable
e.preventDefault();
document.execCommand(
"insertText",
false,
e.clipboardData.getData("text/plain"),
);
}}
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
style={{
fontSize: toPx(fontSizePct, size.width),
lineHeight: 1.5,
color,
fontWeight: bold ? 700 : 400,
textAlign: align ?? "left",
}}
dangerouslySetInnerHTML={initialHtml}
/>
</div>
);
}
export default function TextboxStampLayer({
size,
textboxes,
stamps,
enabled,
ownUserId,
editingId,
onStartEdit,
onCommitEdit,
composer,
onCommitComposer,
onCancelComposer,
onSelect,
}: {
/** ขนาด px ของหน้า (ตาม zoom ปัจจุบัน) */
size: Size;
textboxes: InkTextbox[];
/** ตรายาง (F18/B5) */
stamps: InkStamp[];
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events ของทุกรายการ */
enabled: boolean;
ownUserId: string;
/** TEXTBOX ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
editingId: string | null;
onStartEdit: (id: string | null) => void;
onCommitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void;
/** ตำแหน่งที่กำลังสร้างกล่องใหม่ (null = ไม่มี — parent ส่งเฉพาะหน้าที่คลิก) */
composer: {
position: Point;
fontSizePct: number;
color: string;
bold?: boolean;
opacity?: number;
align?: "left" | "center" | "right";
} | null;
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
onCancelComposer: () => void;
/** คลิกรายการ → parent set selectedId (F1) */
onSelect: (id: string) => void;
}) {
return (
<div className="pointer-events-none absolute inset-0 z-[4]">
{textboxes.map((t) =>
editingId === t.id ? (
<TextboxEditor
key={t.id}
size={size}
position={t.data.position}
fontSizePct={t.data.fontSize}
color={t.data.color}
bold={t.data.bold}
opacity={t.data.opacity}
align={t.data.align}
enabled={enabled}
initialText={t.data.text}
initialSegments={t.data.segments}
onSave={(text, segments) => onCommitEdit(t.id, text, segments)}
onCancel={() => onStartEdit(null)}
/>
) : (
<div
key={t.id}
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
className="absolute whitespace-pre-wrap break-words"
style={{
left: toPx(t.data.position.x, size.width),
top: toPx(t.data.position.y, size.height),
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
fontSize: toPx(t.data.fontSize, size.width),
lineHeight: 1.5,
color: t.data.color,
fontWeight: t.data.bold ? 700 : 400,
opacity: t.data.opacity ?? 1,
textAlign: t.data.align ?? "left",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "text" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(t.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (enabled && t.userId === ownUserId) onStartEdit(t.id);
}}
>
{t.data.segments?.length
? t.data.segments.map((s, i) => (
<span key={i} style={s.bold ? { fontWeight: 700 } : undefined}>
{s.text}
</span>
))
: t.data.text}
</div>
),
)}
{composer && (
<TextboxEditor
size={size}
position={composer.position}
fontSizePct={composer.fontSizePct}
color={composer.color}
bold={composer.bold}
opacity={composer.opacity}
align={composer.align}
enabled={enabled}
initialText=""
onSave={onCommitComposer}
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5) */}
{stamps.map((s) => {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
return (
<div
key={s.id}
className="absolute flex items-center justify-center overflow-hidden text-center font-bold"
style={{
left: toPx(s.data.position.x, size.width),
top: toPx(s.data.position.y, size.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontSize: w * STAMP_FONT_RATIO,
lineHeight: 1.2,
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
>
{s.data.label}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,250 @@
"use client";
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import type { HighlightStyle } from "@/db/schema";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
export const INK_COLORS = [
"#ef4444", // แดง (default ปากกา/รูปทรง)
"#f97316", // ส้ม
"#fde047", // เหลือง (default ไฮไลต์)
"#22c55e", // เขียว
"#14b8a6", // เขียวอมฟ้า
"#3b82f6", // น้ำเงิน
"#8b5cf6", // ม่วง
"#ec4899", // ชมพู
"#111827", // ดำ (default textbox)
"#6b7280", // เทา
];
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย + ตัวควบคุมขวา */
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-2 px-1 py-1">
<span className="w-11 shrink-0 pt-1 text-[10px] font-medium text-slate-400">{label}</span>
{children}
</div>
);
}
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div className="flex flex-1 flex-wrap items-center gap-1.5">
{INK_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
onClick={() => onChange(c)}
/>
))}
<label
title="เลือกสีอื่น"
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`}
style={{
background:
"conic-gradient(#ef4444, #f97316, #fde047, #22c55e, #14b8a6, #3b82f6, #8b5cf6, #ec4899, #ef4444)",
}}
>
<input
type="color"
aria-label="เลือกสีอื่น"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
);
}
/** ช่องตัวเลข combobox — กดที่ input เปิดรายการค่า preset ให้เลือก (หรือพิมพ์เองได้)
* + ปุ่ม ▲▼ ปรับค่าทีละ stepBy (clamp ใน min–max) */
export function NumberCombo({
value,
options,
min,
max,
step,
stepBy,
suffix,
onChange,
}: {
value: number;
options: number[];
min: number;
max: number;
step: number;
/** ละขั้นของปุ่ม ▲▼ (default 1 — เช่น fontSize ใช้ 0.1) */
stepBy?: number;
/** ต่อท้ายค่าในแถวรายการ (เช่น "%") */
suffix?: string;
onChange: (v: number) => void;
}) {
const [open, setOpen] = useState(false);
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
const [draft, setDraft] = useState(String(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setDraft(String(value)), [value]);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
function commitDraft() {
const v = Number(draft);
if (draft.trim() !== "" && Number.isFinite(v)) {
const clamped = Math.min(max, Math.max(min, Number(v.toFixed(2))));
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
} else {
setDraft(String(value));
}
}
/** ▲▼ ปรับค่าทีละ stepBy จาก draft ล่าสุด (ค่าที่พิมพ์เองก็ต่อจากนั้นได้) — clamp ใน min–max
* ชนขอบแล้วค่าไม่เดิน (next === value) ไม่ยิง onChange เช่นเดียวกับ commitDraft */
function stepValue(dir: 1 | -1) {
const by = stepBy ?? 1;
const base = draft.trim() !== "" && Number.isFinite(Number(draft)) ? Number(draft) : value;
const next = Math.min(max, Math.max(min, Number((base + dir * by).toFixed(2))));
setDraft(String(next));
if (next !== value) onChange(next);
}
return (
<div
ref={rootRef}
className="relative"
onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) {
e.stopPropagation();
setOpen(false);
}
}}
>
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input
type="number"
value={draft}
min={min}
max={max}
step={step}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onFocus={(e) => {
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
}}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}}
className="w-14 border-0 px-1.5 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="flex flex-col border-l border-slate-300">
<button
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(1)}
>
<ChevronUp className="h-2.5 w-2.5" />
</button>
<button
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className="flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-2.5 w-2.5" />
</button>
</div>
</div>
{open && (
<div
role="listbox"
className="absolute right-0 top-full z-[60] mt-1 w-16 overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
>
{options.map((opt) => (
<button
key={opt}
type="button"
role="option"
aria-selected={value === opt}
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
value === opt
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
{opt}
{suffix}
{value === opt && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
</div>
);
}
/** ไอคอนเส้นขยุกขยิก (squiggle) — วาดเองเพราะ lucide ไม่มี */
export function SquiggleIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<path d="M2 15 q2.5 -5 5 0 t5 0 t5 0 t5 0" />
</svg>
);
}
/** รายการรูปแบบการมาร์กข้อความ + ไอคอน — ใช้ใน dropdown ของ tool highlight และแผงของกรอบ selection */
export const HIGHLIGHT_STYLE_BTNS: {
key: HighlightStyle;
label: string;
Icon: ComponentType<{ className?: string }>;
}[] = [
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter },
{ key: "underline", label: "เส้นใต้", Icon: Underline },
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },
];

View file

@ -0,0 +1,831 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
ArrowRight,
Bookmark,
Check,
ChevronDown,
ChevronRight,
Circle,
Clock,
Copy,
EyeOff,
Highlighter,
MessageSquare,
Minus,
Pencil,
Plus,
Search,
Square,
Stamp,
StickyNote,
Trash2,
Type,
Underline,
Strikethrough,
X,
} from "lucide-react";
import {
listVersions,
restoreVersion,
searchAnnotations,
} from "@/app/actions/annotations";
import type { AnnotationVersionDTO } from "@/db/schema";
import type {
AnnotationData,
AnnotationDTO,
BookmarkDTO,
CommentData,
DrawData,
HighlightData,
RedactionData,
ShapeData,
} from "@/db/schema";
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
const cls = "h-4 w-4 shrink-0 text-slate-400";
if (type === "HIGHLIGHT") {
const style = (data as HighlightData).style ?? "highlight";
if (style === "underline") return <Underline className={cls} />;
if (style === "strikethrough") return <Strikethrough className={cls} />;
return <Highlighter className={cls} />; // highlight / squiggle
}
if (type === "DRAW")
return (data as DrawData).pen === "highlighter" ? (
<Highlighter className={cls} />
) : (
<Pencil className={cls} />
);
if (type === "REDACTION") return <EyeOff className={cls} />;
if (type === "TEXTBOX") return <Type className={cls} />;
if (type === "STAMP") return <Stamp className={cls} />;
if (type === "SHAPE") {
const shape = (data as ShapeData).shape;
if (shape === "rect") return <Square className={cls} />;
if (shape === "ellipse") return <Circle className={cls} />;
if (shape === "line") return <Minus className={cls} />;
return <ArrowRight className={cls} />;
}
return <MessageSquare className={cls} />;
}
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
return note ? { ...data, note } : { ...data, note: undefined };
}
// ─── N2: helper โครงสร้างต้นไม้ bookmark (3 ระดับ) — คำนวณบน state ที่โหลดครบ ────
// (ปกติลึกสุด 3 — cap กันลูปไม่รู้จบหากข้อมูลวนซ้ำ)
/** ความลึกของ bookmark (root = 1) — เดินขึ้นตาม parentId (parent หาย = ถือเป็น root) */
function bookmarkDepth(b: BookmarkDTO, byId: Map<string, BookmarkDTO>): number {
let depth = 1;
let cur: BookmarkDTO | undefined = b;
while (cur?.parentId && depth <= 100) {
const parent = byId.get(cur.parentId);
if (!parent) break;
depth += 1;
cur = parent;
}
return depth;
}
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) */
function subtreeHeight(b: BookmarkDTO, kidsOf: (id: string) => BookmarkDTO[], level = 1): number {
if (level > 100) return 1; // กันข้อมูลวนซ้ำ
const kids = kidsOf(b.id);
return kids.length === 0
? 1
: 1 + Math.max(...kids.map((k) => subtreeHeight(k, kidsOf, level + 1)));
}
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) */
function isDescendantOf(id: string, ancestorId: string, byId: Map<string, BookmarkDTO>): boolean {
let cur = byId.get(id);
for (let steps = 0; cur?.parentId && steps <= 100; steps += 1) {
if (cur.parentId === ancestorId) return true;
cur = byId.get(cur.parentId);
}
return false;
}
/** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */
export default function AnnotationSidebar({
annotations,
bookmarks,
fileId,
userId,
userFilter,
users,
onUserFilterChange,
replyCounts,
onDeleteAnnotation,
onUpdateAnnotation,
onDeleteBookmark,
onAddSubBookmark,
onUpdateBookmark,
onGoToBookmark,
onGoToAnnotation,
}: {
annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[];
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
fileId: string;
userId: string;
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
userFilter: string;
users: { userId: string; userName: string }[];
onUserFilterChange: (value: string) => void;
/** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */
replyCounts: Record<string, number>;
onDeleteAnnotation: (id: string) => void;
/** แก้ไข data ของรายการของตัวเอง (โน้ตคำอธิบาย) — parent ทำ optimistic + updateAnnotation */
onUpdateAnnotation: (id: string, data: AnnotationData) => void;
onDeleteBookmark: (id: string) => void;
/** N2: ปุ่ม + ที่หัวข้อ — เพิ่ม bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนั้นทันที */
onAddSubBookmark: (parentId: string) => void;
/** N2: แก้ bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (patch เฉพาะ field ที่เปลี่ยน) */
onUpdateBookmark: (
id: string,
patch: { title?: string; note?: string | null; parentId?: string | null },
) => void;
onGoToBookmark: (b: BookmarkDTO) => void;
onGoToAnnotation: (a: AnnotationDTO) => void;
}) {
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
// ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
const [searching, setSearching] = useState(false);
async function runSearch(e: FormEvent) {
e.preventDefault();
const q = searchQuery.trim();
if (!q || !fileId) return;
setSearching(true);
try {
setSearchResults(await searchAnnotations(fileId, q));
} catch (err) {
console.error("ค้นหาไม่สำเร็จ", err);
setSearchResults([]);
} finally {
setSearching(false);
}
}
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
const [historyFor, setHistoryFor] = useState<AnnotationDTO | null>(null);
const [versions, setVersions] = useState<AnnotationVersionDTO[] | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
async function openHistory(a: AnnotationDTO) {
setHistoryFor(a);
setVersions(null);
setHistoryError(null);
try {
setVersions(await listVersions(a.id));
} catch (err) {
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
}
}
/** กู้คืน = เขียนกลับ row เดิม (ต้นทางถูกลบไปแล้ว = สร้างใหม่ id ใหม่)
* sync ผ่าน SSE update/create event (รวมแท็บนี้) */
async function handleRestore(versionId: string) {
setRestoringId(versionId);
try {
await restoreVersion(versionId, userId);
setHistoryFor(null);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
} finally {
setRestoringId(null);
}
}
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
// กับ "จัดการแล้ว" (comment ที่ถูก resolve)
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
const [copiedId, setCopiedId] = useState<string | null>(null);
// FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail
// 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
const copyTimerRef = useRef<number | null>(null);
function copyAnnotationLink(a: AnnotationDTO) {
const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`;
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
copyTimerRef.current = null;
}
const show = (ok: boolean) => {
setCopiedId(ok ? a.id : null);
setCopyFailedId(ok ? null : a.id);
copyTimerRef.current = window.setTimeout(() => {
copyTimerRef.current = null;
setCopiedId(null);
setCopyFailedId(null);
}, 1500);
};
// บริบทที่ไม่มี clipboard API (HTTP ที่ไม่ใช่ localhost) — optional chaining
// จะตัดทั้ง chain ทิ้ง ต้องตรวจก่อนถึงจะมี feedback fail จริง
if (!navigator.clipboard?.writeText) {
show(false);
return;
}
navigator.clipboard.writeText(url).then(
() => show(true),
() => show(false),
);
}
// ─── โน้ตคำอธิบายของรายการ (N1) — แก้ไขเฉพาะรายการของตัวเอง แสดงให้ทุกคน ─────
const [noteFor, setNoteFor] = useState<AnnotationDTO["id"] | null>(null);
const [noteDraft, setNoteDraft] = useState("");
function startNoteEdit(a: AnnotationDTO) {
setNoteFor(a.id);
setNoteDraft(annotationNote(a));
}
function saveNote(a: AnnotationDTO) {
const msg = noteDraft.trim();
setNoteFor(null);
if (a.type === "COMMENT" || msg === annotationNote(a)) return; // ไม่มีอะไรเปลี่ยน
onUpdateAnnotation(a.id, withNote(a.data as Exclude<AnnotationData, CommentData>, msg));
}
// ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ──
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const));
const [editBookmark, setEditBookmark] = useState<{
id: string;
title: string;
note: string;
parentId: string | null;
} | null>(null);
function toggleCollapsed(id: string) {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function startBookmarkEdit(b: BookmarkDTO) {
setEditBookmark({
id: b.id,
title: b.title,
note: b.note ?? "",
parentId: b.parentId,
});
}
function saveBookmarkEdit(b: BookmarkDTO) {
if (!editBookmark || editBookmark.id !== b.id) return;
const patch: { title?: string; note?: string | null; parentId?: string | null } = {};
const title = editBookmark.title.trim();
if (title && title !== b.title) patch.title = title;
const note = editBookmark.note.trim();
if (note !== (b.note ?? "")) patch.note = note || null;
if (editBookmark.parentId !== (b.parentId ?? null)) {
patch.parentId = editBookmark.parentId;
}
setEditBookmark(null);
if (Object.keys(patch).length > 0) onUpdateBookmark(b.id, patch);
}
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
const children = bookmarks.filter((x) => x.parentId === b.id);
const hasChildren = children.length > 0;
const collapsed = collapsedIds.has(b.id);
const editing = editBookmark?.id === b.id;
// FIX-9: guard null ครั้งเดียวต้น branch แทน editBookmark! ซ้ำ 6 จุด
const eb = editing ? editBookmark : null;
// เป้าหมายที่ย้ายเข้าได้ = ไม่ใช่ตัวเอง/ลูกหลาน และความลึกแม่ + ความสูง subtree ตัวเอง ≤ 3 ระดับ
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
const movableTo = editing
? bookmarks.filter(
(t) =>
t.id !== b.id &&
!isDescendantOf(t.id, b.id, bookmarkById) &&
bookmarkDepth(t, bookmarkById) +
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
3,
)
: [];
return (
<li
key={b.id}
className={`group rounded hover:bg-slate-100 ${
depth === 1 ? "px-1 py-0.5" : `py-0.5 pr-1 ${depth === 2 ? "pl-5" : "pl-9"}`
}`}
>
{eb ? (
<div className="py-1">
<input
autoFocus
value={eb.title}
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") saveBookmarkEdit(b);
if (e.key === "Escape") setEditBookmark(null);
}}
placeholder="ชื่อหัวข้อ"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<textarea
rows={2}
value={eb.note}
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") setEditBookmark(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="mt-1 text-[10px] text-slate-400">วางที่</p>
<select
value={eb.parentId ?? ""}
onChange={(e) =>
setEditBookmark({ ...eb, parentId: e.target.value || null })
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot;
</option>
))}
</select>
<div className="mt-1.5 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={() => setEditBookmark(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
onClick={() => saveBookmarkEdit(b)}
>
บันทึก
</button>
</div>
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
{hasChildren ? (
<button
type="button"
aria-label={collapsed ? "กางหัวข้อ" : "หุบหัวข้อ"}
className="shrink-0 text-slate-400 hover:text-slate-600"
onClick={() => toggleCollapsed(b.id)}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-1.5 text-left"
onClick={() => onGoToBookmark(b)}
>
<Bookmark
className={`h-3.5 w-3.5 shrink-0 ${
depth === 1 ? "text-amber-500" : "text-amber-300"
}`}
/>
<span className="min-w-0">
<span
className={`block truncate ${
depth === 1 ? "text-xs font-medium text-slate-700" : "text-xs"
}`}
>
{b.title}
{hasChildren && (
<span className="ml-1 font-normal text-slate-400">({children.length})</span>
)}
</span>
<span className="block text-[10px] text-slate-400">หน้า {b.pageIndex + 1}</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
</span>
)}
</span>
</button>
{b.userId === userId && (
<>
<button
type="button"
aria-label="แก้ไข bookmark"
title="แก้ชื่อ/โน้ต/ย้ายหัวข้อ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
onClick={() => startBookmarkEdit(b)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="ลบ bookmark"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
onClick={() => onDeleteBookmark(b.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
{/* ปุ่ม + ตอน depth < 3 — ดัก bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนี้ */}
{depth < 3 && userId !== "" && (
<button
type="button"
aria-label="เพิ่มหน้าที่ดูอยู่เป็น sub ของหัวข้อนี้"
title="เพิ่มหน้าที่ดูอยู่เป็น sub"
className="shrink-0 text-slate-300 hover:text-emerald-600 group-hover:text-slate-400"
onClick={() => onAddSubBookmark(b.id)}
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{hasChildren && !collapsed && (
<ul className="space-y-0.5">
{children.map((c) => renderBookmarkNode(c, depth + 1))}
</ul>
)}
</li>
);
};
const renderAnnotationRow = (a: AnnotationDTO) => {
const note = annotationNote(a);
const editingNote = noteFor === a.id;
return (
<li
key={a.id}
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${
a.resolved ? "opacity-60" : ""
}`}
>
<div className="flex items-center gap-2">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
onClick={() => {
// ระหว่างแก้โน้ต: คลิกข้อความไม่กระโดดหน้า (กัน scroll หนีตอน editor เปิดอยู่)
if (editingNote) return;
onGoToAnnotation(a);
}}
>
<TypeIcon type={a.type} data={a.data} />
<span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400">
หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}`
: ""}
</span>
</span>
</button>
{/* F12/C4: คัดลอก deep link ไปยังตำแหน่ง annotation นี้ */}
<button
type="button"
aria-label="คัดลอกลิงก์ไปยัง annotation นี้"
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "คัดลอกลิงก์"}
className={`shrink-0 ${
copiedId === a.id
? "text-emerald-500"
: copyFailedId === a.id
? "text-red-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
}`}
onClick={() => copyAnnotationLink(a)}
>
{copiedId === a.id ? (
<Check className="h-4 w-4" />
) : copyFailedId === a.id ? (
<X className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
{a.userId === userId && (
<>
{/* N1: โน้ตคำอธิบาย (เหตุผลของรายการ) — ยกเว้น COMMENT ที่มี message อยู่แล้ว */}
{a.type !== "COMMENT" && (
<button
type="button"
aria-label={note ? "แก้ไขโน้ต" : "เพิ่มโน้ต"}
title="โน้ต"
className={`shrink-0 ${
note
? "text-amber-400 hover:text-amber-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
}`}
onClick={() => (editingNote ? setNoteFor(null) : startNoteEdit(a))}
>
<StickyNote className="h-4 w-4" />
</button>
)}
{/* F19/A7: ประวัติการเปลี่ยนแปลง + กู้คืน — เฉพาะรายการของตัวเอง */}
<button
type="button"
aria-label="ดูประวัติการเปลี่ยนแปลง"
title="ประวัติ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
onClick={() => void openHistory(a)}
>
<Clock className="h-4 w-4" />
</button>
<button
type="button"
aria-label="ลบ annotation"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
onClick={() => onDeleteAnnotation(a.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
{editingNote ? (
<div className="mt-1 pl-6">
<textarea
autoFocus
rows={2}
value={noteDraft}
onChange={(e) => setNoteDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของ viewer
if (e.key === "Escape") setNoteFor(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
}}
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] 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={() => setNoteFor(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
onClick={() => saveNote(a)}
>
บันทึก
</button>
</div>
</div>
) : (
note && (
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
</p>
)
)}
</li>
);
};
return (
<div className="flex h-full flex-col">
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
<div className="shrink-0 border-b border-slate-200 p-2">
<select
aria-label="กรอง annotation ตามผู้เขียน"
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
value={userFilter}
onChange={(e) => onUserFilterChange(e.target.value)}
>
<option value="all">แสดง annotation ของทุกคน</option>
<option value="mine">เฉพาะของฉัน</option>
{users.map((u) => (
<option key={u.userId} value={u.userId}>
เฉพาะ {u.userName}
</option>
))}
</select>
</div>
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — คลิกผลลัพธ์ = กระโดดไปตำแหน่ง + เลือก */}
<form
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
onSubmit={(e) => void runSearch(e)}
>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ค้นหาในหมายเหตุ…"
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<button
type="submit"
disabled={!searchQuery.trim() || searching}
className="shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40"
>
{searching ? "…" : "หา"}
</button>
</form>
{searchResults !== null && (
<div className="shrink-0 border-b border-slate-200 p-2">
<p className="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{searchResults.length > 0
? `ผลการค้นหา (${searchResults.length})`
: "ผลการค้นหา"}
</p>
{searchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-slate-400">ไม่พบ</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto">
{searchResults.map((a) => (
<li key={a.id}>
<button
type="button"
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100"
onClick={() => onGoToAnnotation(a)}
>
<TypeIcon type={a.type} data={a.data} />
<span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400">
หน้า {a.pageIndex + 1} · {a.userName}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex shrink-0 border-b border-slate-200">
{(
[
["bookmarks", `Bookmarks (${bookmarks.length})`],
["annotations", `Annotations (${annotations.length})`],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition ${
tab === key
? "border-b-2 border-primary text-slate-900"
: "text-slate-400 hover:text-slate-600"
}`}
onClick={() => setTab(key)}
>
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "bookmarks" ? (
bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400">
ยังไม่มี bookmark — กดปุ่ม bookmark บน toolbar เพื่อเพิ่ม
</p>
) : (
<ul className="space-y-0.5">
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามเวลาสร้าง — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
{bookmarks
.filter((b) => !b.parentId)
.map((b) => renderBookmarkNode(b, 1))}
</ul>
)
) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400">
ยังไม่มี annotation — คลุมดำ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
</p>
) : (
<>
{pendingItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
ค้างดำเนินการ
</p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
</section>
)}
{resolvedItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-emerald-500">
จัดการแล้ว
</p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
</section>
)}
</>
)}
</div>
{/* F19/A7: dialog render ผ่าน portal ไป document.body —
aside.sticky สร้าง stacking context ครอบ z-50 ของ dialog ไว้ ทำให้ textLayer
(z-[2] ระดับ root) ทับปุ่มจนคลิกไม่ได้ (เจอตอน verify ด้วยเบราว์เซอร์จริง) */}
{typeof document === "undefined"
? null // SSR ไม่มี document — client component ก็ถูก render ฝั่ง server สำหรับ HTML แรก
: createPortal(
<>
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
<div className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl">
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-slate-400">
{annotationPreviewText(historyFor)}
</p>
{historyError && (
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
{historyError}
</p>
)}
{versions === null && !historyError && (
<p className="mt-3 text-xs text-slate-400">กำลังโหลด…</p>
)}
{versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-slate-400">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p>
)}
{versions !== null && versions.length > 0 && (
<ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
{versions.map((v) => (
<li key={v.id} className="flex items-center gap-2 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span>
<span className="block text-[11px] text-slate-400" suppressHydrationWarning>
{new Date(v.createdAt).toLocaleString("th-TH")}
</span>
</span>
<button
type="button"
disabled={restoringId !== null}
className="shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40"
onClick={() => void handleRestore(v.id)}
>
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
</button>
</li>
))}
</ul>
)}
<div className="mt-3 flex justify-end text-xs">
<button
type="button"
className="rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setHistoryFor(null)}
>
ปิด
</button>
</div>
</div>
</div>
)}
</>
,
document.body
)}
</div>
);
}