All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
700 lines
32 KiB
TypeScript
700 lines
32 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react";
|
||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
|
||
|
||
import type {
|
||
AnnotationData,
|
||
AnnotationDTO,
|
||
HighlightData,
|
||
HighlightStyle,
|
||
TextboxData,
|
||
} from "@/db/schema";
|
||
import {
|
||
TEXTBOX_MAX_WIDTH_PCT,
|
||
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 = {
|
||
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||
mode: "move" | "scale" | "wleft" | "wright";
|
||
/** ตำแหน่ง 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,
|
||
onStartEdit,
|
||
}: {
|
||
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;
|
||
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
|
||
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
|
||
onStartEdit?: (id: string) => 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" | "wleft" | "wright",
|
||
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 if (st.mode === "wleft" || st.mode === "wright") {
|
||
// TEXTBOX กว้างคงที่ — ขอบที่จับขยับ อีกขอบตายตัว (position = มุมซ้ายบนเสมอ:
|
||
// ลากขอบซ้าย = เลื่อนกล่อง+เปลี่ยนกว้าง, ลากขอบขวา = เปลี่ยนกว้างอย่างเดียว)
|
||
// ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ
|
||
const d = st.origData;
|
||
if (!("fontSize" in d)) {
|
||
next = d;
|
||
} else {
|
||
const MIN_W = 2; // % ของหน้า — กล่องแคบสุดที่ยังใช้ได้จริง
|
||
const right = st.origBoxPct.x + st.origBoxPct.width;
|
||
let left = st.origBoxPct.x;
|
||
let w = st.origBoxPct.width;
|
||
if (st.mode === "wright") {
|
||
const r = clamp(
|
||
cur.x,
|
||
st.origBoxPct.x + MIN_W,
|
||
Math.min(100, st.origBoxPct.x + TEXTBOX_MAX_WIDTH_PCT),
|
||
);
|
||
w = r - st.origBoxPct.x;
|
||
} else {
|
||
const l = clamp(
|
||
cur.x,
|
||
Math.max(0, right - TEXTBOX_MAX_WIDTH_PCT),
|
||
right - MIN_W,
|
||
);
|
||
left = l;
|
||
w = right - l;
|
||
}
|
||
next = { ...d, width: w, position: { ...d.position, x: left } };
|
||
}
|
||
} else if ("scale" in st.origData) {
|
||
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
||
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
|
||
const rawK =
|
||
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
|
||
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
|
||
2;
|
||
const k = clampAxisScale(
|
||
clampAxisScale(
|
||
rawK,
|
||
st.anchorPct.x,
|
||
st.origBoxPct.x,
|
||
st.origBoxPct.x + st.origBoxPct.width,
|
||
st.origBoxPct.width,
|
||
),
|
||
st.anchorPct.y,
|
||
st.origBoxPct.y,
|
||
st.origBoxPct.y + st.origBoxPct.height,
|
||
st.origBoxPct.height,
|
||
);
|
||
next = scaleAnnotationData(st.origData, st.anchorPct, k, k);
|
||
} 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;
|
||
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
|
||
// และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง
|
||
const isFixedTextbox = textboxData?.width !== undefined;
|
||
|
||
// มุมทั้ง 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 ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
|
||
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
|
||
<div
|
||
className="absolute cursor-move touch-none sel-strip"
|
||
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 touch-none sel-strip"
|
||
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 touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||
style={{
|
||
left: boxPx.x - STRIP_PX / 2,
|
||
top: boxPx.y,
|
||
width: STRIP_PX,
|
||
height: boxPx.height,
|
||
pointerEvents: "auto",
|
||
}}
|
||
onPointerDown={(e) =>
|
||
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
|
||
}
|
||
onPointerMove={moveDrag}
|
||
onPointerUp={endDrag}
|
||
onPointerCancel={cancelDrag}
|
||
/>
|
||
<div
|
||
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "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, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
|
||
}
|
||
onPointerMove={moveDrag}
|
||
onPointerUp={endDrag}
|
||
onPointerCancel={cancelDrag}
|
||
/>
|
||
|
||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
|
||
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
|
||
{!isFixedTextbox && corners.map((c) => (
|
||
<div
|
||
key={c.key}
|
||
className="absolute touch-none sel-handle 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 ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
|
||
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
|
||
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */}
|
||
{editable && !disabled && (
|
||
<div
|
||
ref={panelRef}
|
||
className="absolute w-60 sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
|
||
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}
|
||
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 && (
|
||
<>
|
||
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
|
||
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
|
||
<PanelRow label="ข้อความ">
|
||
<button
|
||
type="button"
|
||
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
|
||
onClick={() => onStartEdit?.(annotation.id)}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
แก้ไขข้อความ
|
||
</button>
|
||
</PanelRow>
|
||
<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 })}
|
||
>
|
||
{/* B ทึบ — เหมือน dropdown ของ tool (ตัวควบคุมชุดเดียวกัน) */}
|
||
<span aria-hidden className="text-[13px] font-black leading-none">
|
||
B
|
||
</span>
|
||
</button>
|
||
</PanelRow>
|
||
<PanelRow label="จัดแนว">
|
||
<div className="flex overflow-hidden rounded-md border border-slate-300">
|
||
{(
|
||
[
|
||
["left", AlignLeft, "ชิดซ้าย"],
|
||
["center", AlignCenter, "กึ่งกลาง"],
|
||
["right", AlignRight, "ชิดขวา"],
|
||
] as const
|
||
).map(([a, AlignIcon, 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 sel-panel-btn 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 sel-panel-btn 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>
|
||
);
|
||
}
|