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:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
617
components/pdf/AnnotationSelectionLayer.tsx
Normal file
617
components/pdf/AnnotationSelectionLayer.tsx
Normal 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
581
components/pdf/InkLayer.tsx
Normal 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
2918
components/pdf/PDFViewer.tsx
Normal file
File diff suppressed because it is too large
Load diff
329
components/pdf/TextboxStampLayer.tsx
Normal file
329
components/pdf/TextboxStampLayer.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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>
|
||||
);
|
||||
}
|
||||
250
components/pdf/ToolControls.tsx
Normal file
250
components/pdf/ToolControls.tsx
Normal 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 },
|
||||
];
|
||||
831
components/sidebar/AnnotationSidebar.tsx
Normal file
831
components/sidebar/AnnotationSidebar.tsx
Normal 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}>
|
||||
ใต้ "{h.title}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue