UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
a020e96bda
commit
21b9958600
10 changed files with 351 additions and 158 deletions
|
|
@ -84,17 +84,17 @@ export default async function SummaryPage({
|
||||||
<span className="block text-xs">
|
<span className="block text-xs">
|
||||||
<span className="font-medium">{annotationTypeLabel(a)}</span>
|
<span className="font-medium">{annotationTypeLabel(a)}</span>
|
||||||
{a.type === "COMMENT" && a.resolved && (
|
{a.type === "COMMENT" && a.resolved && (
|
||||||
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">
|
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-600">
|
||||||
จัดการแล้ว
|
จัดการแล้ว
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-2 text-slate-400">{a.userName}</span>
|
<span className="ml-2 text-slate-400">{a.userName}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm">
|
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
{annotationPreviewText(a)}
|
{annotationPreviewText(a)}
|
||||||
</span>
|
</span>
|
||||||
{annotationNote(a) && (
|
{annotationNote(a) && (
|
||||||
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs text-slate-500">
|
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-500">
|
||||||
โน้ต: {annotationNote(a)}
|
โน้ต: {annotationNote(a)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,11 @@
|
||||||
|
|
||||||
/* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px
|
/* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px
|
||||||
คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼
|
คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼
|
||||||
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถวเองที่ 36px แทน:
|
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px
|
||||||
แตะ input = เปิด preset list (แถวละ 36px จาก rule ด้านบน) คือ touch path
|
แทน (:first-child — แถว chips preset ของ L2 โดน rule ปุ่ม 36px ของเมนู
|
||||||
หลัก ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
|
ครอบอยู่แล้ว ห้ามบังคับสูงซ้ำ): แตะ chips preset = touch path หลัก
|
||||||
[data-tool-menu] .number-combo > div {
|
ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
|
||||||
|
[data-tool-menu] .number-combo > div:first-child {
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
}
|
}
|
||||||
[data-tool-menu] .combo-step {
|
[data-tool-menu] .combo-step {
|
||||||
|
|
@ -197,14 +198,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UX review 2026-09-14 H2: แอปไม่เคยตั้ง font-family เลย ข้อความไทยเลยตกไปที่
|
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
|
||||||
default ของ OS แต่ละเครื่อง — วางฟอนต์ไทยนำหน้า stack (ไม่โหลด webfont
|
Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font
|
||||||
ตามที่เลือกไว้: Leelawadee UI = Windows, Noto Sans Thai = Android/Linux,
|
ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback
|
||||||
Thonburi = macOS) */
|
ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1:
|
||||||
|
Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI
|
||||||
|
(Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */
|
||||||
body {
|
body {
|
||||||
font-family: "Leelawadee UI", "Noto Sans Thai", "Thonburi",
|
font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI",
|
||||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
"Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai",
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
|
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
|
||||||
|
|
@ -249,3 +252,18 @@ header {
|
||||||
.comment-composer {
|
.comment-composer {
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ
|
||||||
|
sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด
|
||||||
|
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
aside[data-sidebar] {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.animate-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Sarabun } from "next/font/google";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
/** typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร
|
||||||
|
* ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build:
|
||||||
|
* ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet
|
||||||
|
* ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) — โหลดเฉพาะ 2 weights ตามแผน
|
||||||
|
* ~100-160KB แลกกับการแสดงผลเหมือนกันทุกอุปกรณ์ รวมกล่อง TEXTBOX ที่ผูก
|
||||||
|
* stack นี้ (T1-C: wrap ข้อความตรงกันข้ามอุปกรณ์จริง) */
|
||||||
|
const sarabun = Sarabun({
|
||||||
|
subsets: ["thai", "latin"],
|
||||||
|
weight: ["400", "600"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-sarabun",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Judge's Note",
|
title: "Judge's Note",
|
||||||
description: "ระบบ PDF Annotation สำหรับ e-Court",
|
description: "ระบบ PDF Annotation สำหรับ e-Court",
|
||||||
|
|
@ -13,7 +27,7 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="th">
|
<html lang="th" className={sarabun.variable}>
|
||||||
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
|
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
20
app/page.tsx
20
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { asc } from "drizzle-orm";
|
import { asc } from "drizzle-orm";
|
||||||
import { FileImage, FileText } from "lucide-react";
|
import { ChevronRight, FileImage, FileText } from "lucide-react";
|
||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { files } from "@/db/schema";
|
import { files } from "@/db/schema";
|
||||||
|
|
@ -48,7 +48,7 @@ export default async function HomePage() {
|
||||||
<div className="mt-8 flex items-baseline justify-between">
|
<div className="mt-8 flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
|
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<span className="text-xs text-slate-400">{rows.length} ไฟล์</span>
|
<span className="text-xs tabular-nums text-slate-500">{rows.length} ไฟล์</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<UploadFileForm />
|
<UploadFileForm />
|
||||||
|
|
@ -59,7 +59,7 @@ export default async function HomePage() {
|
||||||
<p className="mt-3 text-sm font-medium text-slate-500">
|
<p className="mt-3 text-sm font-medium text-slate-500">
|
||||||
ยังไม่มีไฟล์ในระบบ
|
ยังไม่มีไฟล์ในระบบ
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,7 +68,7 @@ export default async function HomePage() {
|
||||||
{rows.map((f) => (
|
{rows.map((f) => (
|
||||||
<li
|
<li
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
className="group relative flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||||
|
|
@ -81,19 +81,21 @@ export default async function HomePage() {
|
||||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||||
{f.title}
|
{f.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-xs text-slate-400">
|
<span className="mt-0.5 block text-xs text-slate-500">
|
||||||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||||
{kindBadge(f.pdfUrl)}
|
{kindBadge(f.pdfUrl)}
|
||||||
</span>
|
</span>
|
||||||
{new Date(f.createdAt).toLocaleString("th-TH")}
|
{new Date(f.createdAt).toLocaleString("th-TH")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronRight aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
{/* M4 (UX audit 2026-09-16): ลิงก์กางเต็มการ์ด — จอสัมผัสจิ้มตรงไหนก็เข้าได้
|
||||||
|
(เดิมปุ่ม "Annotate" ~30px อย่างเดียว และเป็นภาษาอังกฤษใน UI ไทย) */}
|
||||||
<Link
|
<Link
|
||||||
href={`/annotate/${f.id}`}
|
href={`/annotate/${f.id}`}
|
||||||
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90"
|
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||||
>
|
aria-label={`เปิด ${f.title}`}
|
||||||
Annotate
|
/>
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import { uploadFile } from "@/app/actions/files";
|
||||||
|
|
||||||
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
|
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
|
||||||
|
|
||||||
|
/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client
|
||||||
|
* ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ
|
||||||
|
* บนเน็ตช้าก่อนเจอ error ของ server */
|
||||||
|
const MAX_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
export default function UploadFileForm() {
|
export default function UploadFileForm() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
@ -16,6 +21,12 @@ export default function UploadFileForm() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.currentTarget;
|
const form = e.currentTarget;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const input = form.elements.namedItem("file") as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (file && file.size > MAX_BYTES) {
|
||||||
|
setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า");
|
||||||
|
return;
|
||||||
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await uploadFile(new FormData(form));
|
const result = await uploadFile(new FormData(form));
|
||||||
|
|
@ -42,12 +53,12 @@ export default function UploadFileForm() {
|
||||||
accept={ACCEPT}
|
accept={ACCEPT}
|
||||||
required
|
required
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary"
|
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="ml-auto shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
className="ml-auto shrink-0 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
|
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/ut
|
||||||
import { STAMP_COLOR } from "@/utils/annotation-display";
|
import { STAMP_COLOR } from "@/utils/annotation-display";
|
||||||
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
|
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
|
||||||
|
|
||||||
|
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
|
||||||
|
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
|
||||||
|
const FOCUS_RING_CLS =
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
|
||||||
|
|
||||||
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
|
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
|
||||||
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
|
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
|
||||||
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
|
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
|
||||||
|
|
@ -519,7 +524,7 @@ export default function AnnotationSelectionLayer({
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
aria-pressed={(highlightData.style ?? "highlight") === key}
|
aria-pressed={(highlightData.style ?? "highlight") === key}
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${
|
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${
|
||||||
(highlightData.style ?? "highlight") === key
|
(highlightData.style ?? "highlight") === key
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-white"
|
||||||
: "text-slate-500 hover:bg-slate-100"
|
: "text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -589,7 +594,7 @@ export default function AnnotationSelectionLayer({
|
||||||
<PanelRow label="ข้อความ">
|
<PanelRow label="ข้อความ">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => onStartEdit?.(annotation.id)}
|
onClick={() => onStartEdit?.(annotation.id)}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
|
@ -630,7 +635,7 @@ export default function AnnotationSelectionLayer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="สลับตัวหนาทั้งกล่อง"
|
aria-label="สลับตัวหนาทั้งกล่อง"
|
||||||
aria-pressed={!!textboxData.bold}
|
aria-pressed={!!textboxData.bold}
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${
|
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${
|
||||||
textboxData.bold
|
textboxData.bold
|
||||||
? "border-primary bg-primary text-white"
|
? "border-primary bg-primary text-white"
|
||||||
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -657,7 +662,7 @@ export default function AnnotationSelectionLayer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`จัดแนว${aLabel}`}
|
aria-label={`จัดแนว${aLabel}`}
|
||||||
aria-pressed={(textboxData.align ?? "left") === a}
|
aria-pressed={(textboxData.align ?? "left") === a}
|
||||||
className={`px-2 py-1 transition ${
|
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
|
||||||
(textboxData.align ?? "left") === a
|
(textboxData.align ?? "left") === a
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-white"
|
||||||
: "text-slate-500 hover:bg-slate-100"
|
: "text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -678,7 +683,7 @@ export default function AnnotationSelectionLayer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
|
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
|
||||||
title="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"
|
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 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => onBookmark(boxPct.y)}
|
onClick={() => onBookmark(boxPct.y)}
|
||||||
>
|
>
|
||||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
|
|
@ -687,7 +692,7 @@ export default function AnnotationSelectionLayer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="ลบรายการนี้"
|
aria-label="ลบรายการนี้"
|
||||||
title="ลบ"
|
title="ลบ"
|
||||||
className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50"
|
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => onDelete(annotation.id)}
|
onClick={() => onDelete(annotation.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ import {
|
||||||
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
|
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
|
||||||
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
||||||
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
||||||
import AnnotationSidebar from "../sidebar/AnnotationSidebar";
|
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
|
||||||
import Select from "../Select";
|
import Select from "../Select";
|
||||||
import {
|
import {
|
||||||
ColorSwatches,
|
ColorSwatches,
|
||||||
|
|
@ -228,7 +228,10 @@ const SCROLL_BELOW_HEADER_GAP = 40;
|
||||||
function scrollToBelowHeader(absoluteTop: number): void {
|
function scrollToBelowHeader(absoluteTop: number): void {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
|
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
|
||||||
behavior: "smooth",
|
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
? "auto"
|
||||||
|
: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,15 +470,16 @@ function ReplyBox({
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
replies: ReplyDTO[];
|
replies: ReplyDTO[];
|
||||||
ownUserId: string;
|
ownUserId: string;
|
||||||
onCreateReply: (annotationId: string, message: string) => void;
|
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
|
||||||
onDeleteReply: (id: string) => void;
|
onDeleteReply: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
function submit() {
|
async function submit() {
|
||||||
const msg = draft.trim();
|
const msg = draft.trim();
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
setDraft("");
|
// H1 (UX audit 2026-09-16): เคลียร์ draft เฉพาะตอนสำเร็จ — fail แล้วข้อความ
|
||||||
onCreateReply(annotationId, msg);
|
// ที่พิมพ์เองต้องอยู่ต่อให้กดส่งใหม่ได้ (เดิมเคลียร์ก่อนเรียก action = หายทั้งข้อ)
|
||||||
|
if (await onCreateReply(annotationId, msg)) setDraft("");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 border-t border-slate-100 pt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="mt-2 border-t border-slate-100 pt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -501,7 +505,7 @@ function ReplyBox({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="ลบคำตอบ"
|
aria-label="ลบคำตอบ"
|
||||||
className="shrink-0 text-slate-300 hover:text-red-500"
|
className={`coarse-36 shrink-0 rounded text-slate-300 hover:text-red-500 ${FOCUS_RING_CLS}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteReply(r.id);
|
onDeleteReply(r.id);
|
||||||
|
|
@ -520,7 +524,7 @@ function ReplyBox({
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก
|
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก
|
||||||
if (e.key === "Enter") submit();
|
if (e.key === "Enter") void submit();
|
||||||
}}
|
}}
|
||||||
placeholder="ตอบ comment…"
|
placeholder="ตอบ comment…"
|
||||||
className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||||
|
|
@ -528,8 +532,8 @@ function ReplyBox({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim()}
|
||||||
onClick={submit}
|
onClick={() => void submit()}
|
||||||
className="shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40"
|
className={`coarse-36 shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||||
>
|
>
|
||||||
ตอบ
|
ตอบ
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -638,7 +642,7 @@ function PdfPage({
|
||||||
onSetResolved: (id: string, resolved: boolean) => void;
|
onSetResolved: (id: string, resolved: boolean) => void;
|
||||||
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
|
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
|
||||||
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
|
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
|
||||||
onCreateReply: (annotationId: string, message: string) => void;
|
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
|
||||||
onDeleteReply: (id: string) => void;
|
onDeleteReply: (id: string) => void;
|
||||||
onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void;
|
onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void;
|
||||||
onShapeEnd: (data: ShapeData) => void;
|
onShapeEnd: (data: ShapeData) => void;
|
||||||
|
|
@ -963,6 +967,9 @@ function PdfPage({
|
||||||
const x = toPx(a.data.position.x, width);
|
const x = toPx(a.data.position.x, width);
|
||||||
const y = toPx(a.data.position.y, height);
|
const y = toPx(a.data.position.y, height);
|
||||||
const open = openPinId === a.id;
|
const open = openPinId === a.id;
|
||||||
|
// L3 (UX audit 2026-09-16): pin ชิดขวาหน้า — popover เปิดขวาของ pin จะยื่น
|
||||||
|
// เกินกระดาษ 200px+ พลิกไปซ้ายของ pin แทน (pattern เดียวกับ tooltip N1)
|
||||||
|
const flipLeft = x + 12 + 224 > width;
|
||||||
return (
|
return (
|
||||||
<div key={a.id} className="absolute" style={{ left: x, top: y }}>
|
<div key={a.id} className="absolute" style={{ left: x, top: y }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -983,8 +990,13 @@ function PdfPage({
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-3 top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
|
className="absolute top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
|
||||||
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
|
style={{
|
||||||
|
...(flipLeft
|
||||||
|
? { left: width - 232 - x } // ปลายขวา popover อยู่ห่างขอบขวาหน้า 8px
|
||||||
|
: { left: 12 }),
|
||||||
|
pointerEvents: overlayFrozen ? "none" : "auto",
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
|
@ -995,7 +1007,7 @@ function PdfPage({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="แก้ไขข้อความ"
|
aria-label="แก้ไขข้อความ"
|
||||||
title="แก้ไขข้อความ"
|
title="แก้ไขข้อความ"
|
||||||
className="shrink-0 text-slate-400 hover:text-slate-600"
|
className={`coarse-36 shrink-0 rounded text-slate-400 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onStartCommentEdit(a.id);
|
onStartCommentEdit(a.id);
|
||||||
|
|
@ -1031,7 +1043,7 @@ function PdfPage({
|
||||||
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
|
className={`coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => onStartCommentEdit(null)}
|
onClick={() => onStartCommentEdit(null)}
|
||||||
>
|
>
|
||||||
ยกเลิก
|
ยกเลิก
|
||||||
|
|
@ -1039,7 +1051,7 @@ function PdfPage({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!commentEditDraft.trim()}
|
disabled={!commentEditDraft.trim()}
|
||||||
className="coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
|
className={`coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const msg = commentEditDraft.trim();
|
const msg = commentEditDraft.trim();
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
|
@ -1055,7 +1067,11 @@ function PdfPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 whitespace-pre-wrap break-words">{a.data.message}</p>
|
// T2/T3 (typography audit): ข้อความหลักไทยที่อ่านต่อเนื่อง = 13px +
|
||||||
|
// leading-relaxed (สระบน+วรรณยุกต์ซ้อนชนกันที่ leading เดิม)
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words text-[13px] leading-relaxed">
|
||||||
|
{a.data.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
|
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
|
||||||
{new Date(a.createdAt).toLocaleString("th-TH")}
|
{new Date(a.createdAt).toLocaleString("th-TH")}
|
||||||
|
|
@ -1063,7 +1079,7 @@ function PdfPage({
|
||||||
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
|
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${
|
className={`coarse-36 mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${FOCUS_RING_CLS} ${
|
||||||
a.resolved
|
a.resolved
|
||||||
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
|
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
|
||||||
: "border border-slate-300 text-slate-500 hover:bg-slate-100"
|
: "border border-slate-300 text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -1137,6 +1153,11 @@ export default function PDFViewer({
|
||||||
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
|
||||||
|
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
|
||||||
|
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const actionErrorTimerRef = useRef<number | null>(null);
|
||||||
const [pageSizes, setPageSizes] = useState<Size[]>([]);
|
const [pageSizes, setPageSizes] = useState<Size[]>([]);
|
||||||
const [tool, setTool] = useState<Tool>("select");
|
const [tool, setTool] = useState<Tool>("select");
|
||||||
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
|
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
|
||||||
|
|
@ -1198,6 +1219,12 @@ export default function PDFViewer({
|
||||||
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
|
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
|
||||||
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
|
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
|
||||||
const bookmarkPendingRef = useRef(false);
|
const bookmarkPendingRef = useRef(false);
|
||||||
|
/** M6 (UX audit 2026-09-16): feedback สั้น ๆ บนปุ่ม 🔖 หลังบันทึกสำเร็จ — sidebar ที่ปิด
|
||||||
|
* อยู่ (ค่า default ของ iPad) ทำให้ไม่มีอะไรบอกว่าเซฟแล้ว ผู้ใช้กดซ้ำจนได้ bookmark ซ้ำ */
|
||||||
|
const [bookmarkSaved, setBookmarkSaved] = useState(false);
|
||||||
|
const bookmarkSavedTimerRef = useRef<number | null>(null);
|
||||||
|
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
|
||||||
|
const [bookmarkSaving, setBookmarkSaving] = useState(false);
|
||||||
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
|
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
|
||||||
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
||||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
|
|
@ -1769,6 +1796,16 @@ export default function PDFViewer({
|
||||||
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
|
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
|
||||||
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
|
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
|
||||||
|
|
||||||
|
/** H1: toast แจ้ง error ของ action 6 วินาที (pattern เดียวกับ exportError) */
|
||||||
|
function showActionError(message: string) {
|
||||||
|
setActionError(message);
|
||||||
|
if (actionErrorTimerRef.current !== null) window.clearTimeout(actionErrorTimerRef.current);
|
||||||
|
actionErrorTimerRef.current = window.setTimeout(() => {
|
||||||
|
actionErrorTimerRef.current = null;
|
||||||
|
setActionError(null);
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateAnnotation(
|
async function handleCreateAnnotation(
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
type: AnnotationType,
|
type: AnnotationType,
|
||||||
|
|
@ -1851,6 +1888,7 @@ export default function PDFViewer({
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
||||||
|
showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
} finally {
|
} finally {
|
||||||
pendingCreatesRef.current.delete(temp.id);
|
pendingCreatesRef.current.delete(temp.id);
|
||||||
}
|
}
|
||||||
|
|
@ -1905,6 +1943,7 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ลบ annotation ไม่สำเร็จ", err);
|
console.error("ลบ annotation ไม่สำเร็จ", err);
|
||||||
|
showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1936,6 +1975,7 @@ export default function PDFViewer({
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
|
||||||
|
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1962,14 +2002,18 @@ export default function PDFViewer({
|
||||||
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
|
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
|
||||||
|
showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
|
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleCreateReply(annotationId: string, message: string) {
|
async function handleCreateReply(
|
||||||
if (!user) return;
|
annotationId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!user) return false;
|
||||||
try {
|
try {
|
||||||
const saved = await createReply({
|
const saved = await createReply({
|
||||||
annotationId,
|
annotationId,
|
||||||
|
|
@ -1980,8 +2024,11 @@ export default function PDFViewer({
|
||||||
setReplies((prev) =>
|
setReplies((prev) =>
|
||||||
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
|
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ตอบ comment ไม่สำเร็จ", err);
|
console.error("ตอบ comment ไม่สำเร็จ", err);
|
||||||
|
showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1992,6 +2039,7 @@ export default function PDFViewer({
|
||||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ลบคำตอบไม่สำเร็จ", err);
|
console.error("ลบคำตอบไม่สำเร็จ", err);
|
||||||
|
showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2103,10 +2151,23 @@ export default function PDFViewer({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M6: ปุ่ม 🔖 เปลี่ยนเป็น ✓ เขียว 1.5 วิ (pattern เดียวกับ copiedId ของ sidebar) */
|
||||||
|
function flashBookmarkSaved() {
|
||||||
|
setBookmarkSaved(true);
|
||||||
|
if (bookmarkSavedTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(bookmarkSavedTimerRef.current);
|
||||||
|
}
|
||||||
|
bookmarkSavedTimerRef.current = window.setTimeout(() => {
|
||||||
|
bookmarkSavedTimerRef.current = null;
|
||||||
|
setBookmarkSaved(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveBookmark() {
|
async function handleSaveBookmark() {
|
||||||
const c = bookmarkComposer;
|
const c = bookmarkComposer;
|
||||||
if (!c || !user || bookmarkPendingRef.current) return;
|
if (!c || !user || bookmarkPendingRef.current) return;
|
||||||
bookmarkPendingRef.current = true;
|
bookmarkPendingRef.current = true;
|
||||||
|
setBookmarkSaving(true);
|
||||||
try {
|
try {
|
||||||
const saved = await createBookmark({
|
const saved = await createBookmark({
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
|
|
@ -2125,11 +2186,13 @@ export default function PDFViewer({
|
||||||
setBookmarks((prev) =>
|
setBookmarks((prev) =>
|
||||||
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
|
||||||
);
|
);
|
||||||
|
flashBookmarkSaved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("บันทึก bookmark ไม่สำเร็จ", err);
|
console.error("บันทึก bookmark ไม่สำเร็จ", err);
|
||||||
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
||||||
} finally {
|
} finally {
|
||||||
bookmarkPendingRef.current = false;
|
bookmarkPendingRef.current = false;
|
||||||
|
setBookmarkSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2437,6 +2500,12 @@ export default function PDFViewer({
|
||||||
window.matchMedia("(pointer: coarse)").matches,
|
window.matchMedia("(pointer: coarse)").matches,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// M3 (UX audit 2026-09-16): hint วิธีเลื่อนหน้าของ tool highlight บนจอสัมผัส (ช่วง
|
||||||
|
// tool นี้ลากที่ไหนก็ไม่เลื่อน — gesture เป็นของ selection) แสดง 3 ครั้งแรกต่อ
|
||||||
|
// session พอ ไม่รบกวนผู้ใช้ที่คุ้นแล้ว
|
||||||
|
const coarseHlHintCountRef = useRef(0);
|
||||||
|
const [coarseHlHintVisible, setCoarseHlHintVisible] = useState(false);
|
||||||
|
|
||||||
// จอสัมผัส + tool highlight — ลากเลือกข้อความเองผ่าน Pointer Events (ไม่พึ่ง
|
// จอสัมผัส + tool highlight — ลากเลือกข้อความเองผ่าน Pointer Events (ไม่พึ่ง
|
||||||
// native selection ของ OS ที่ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles): ลาก → ปล่อย
|
// native selection ของ OS ที่ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles): ลาก → ปล่อย
|
||||||
// = ไฮไลต์ทันที ผ่าน commitHighlight ชุดเดียวกับ desktop / preview ของ hook
|
// = ไฮไลต์ทันที ผ่าน commitHighlight ชุดเดียวกับ desktop / preview ของ hook
|
||||||
|
|
@ -2580,6 +2649,9 @@ export default function PDFViewer({
|
||||||
// ─── Scroll-to จาก sidebar (PLAN.md §6.4 — คำนวณ absolute ครั้งเดียว) ──────
|
// ─── Scroll-to จาก sidebar (PLAN.md §6.4 — คำนวณ absolute ครั้งเดียว) ──────
|
||||||
|
|
||||||
function goToBookmark(b: BookmarkDTO) {
|
function goToBookmark(b: BookmarkDTO) {
|
||||||
|
// M1 (UX audit 2026-09-16): จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้
|
||||||
|
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
|
||||||
|
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||||||
const el = pageWrapRefs.current[b.pageIndex];
|
const el = pageWrapRefs.current[b.pageIndex];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
|
@ -2591,6 +2663,8 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToAnnotation(a: AnnotationDTO) {
|
function goToAnnotation(a: AnnotationDTO) {
|
||||||
|
// M1: จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้ เห็นเป้าหมายทันที
|
||||||
|
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||||||
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
|
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
|
||||||
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
|
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
|
||||||
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
||||||
|
|
@ -2690,6 +2764,13 @@ export default function PDFViewer({
|
||||||
setTextboxComposer(null);
|
setTextboxComposer(null);
|
||||||
setEditingTextboxId(null);
|
setEditingTextboxId(null);
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
|
// M3: เข้า tool highlight บนจอสัมผัส (ปุ่มหรือคีย์ H) — โชว์ hint ตามโควตาครั้งแรก
|
||||||
|
if (t === "highlight" && coarseTouch && coarseHlHintCountRef.current < 3) {
|
||||||
|
coarseHlHintCountRef.current += 1;
|
||||||
|
setCoarseHlHintVisible(true);
|
||||||
|
} else {
|
||||||
|
setCoarseHlHintVisible(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
|
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
|
||||||
|
|
@ -2822,7 +2903,8 @@ export default function PDFViewer({
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="w-16 text-center text-xs tabular-nums">
|
{/* L4: เลข 3 หลักล้น w-16 — ปล่อยกว้างตามเนื้อหา มีขั้นต่ำให้ตัวเลขไม่ขยับ */}
|
||||||
|
<span className="w-auto min-w-[4.5rem] px-1 text-center text-xs tabular-nums">
|
||||||
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
|
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -2904,6 +2986,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === "highlight"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={highlightMenuOpen}
|
aria-expanded={highlightMenuOpen}
|
||||||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||||
|
|
@ -2943,7 +3026,7 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
key={sKey}
|
key={sKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||||
highlightStyle === sKey
|
highlightStyle === sKey
|
||||||
? "bg-slate-100 text-slate-900"
|
? "bg-slate-100 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
|
|
@ -2986,6 +3069,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === "draw"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={drawMenuOpen}
|
aria-expanded={drawMenuOpen}
|
||||||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||||
|
|
@ -3023,7 +3107,7 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||||
drawPen === p
|
drawPen === p
|
||||||
? "bg-slate-100 text-slate-900"
|
? "bg-slate-100 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
|
|
@ -3068,7 +3152,8 @@ export default function PDFViewer({
|
||||||
onChange={setDrawOpacityPct}
|
onChange={setDrawOpacityPct}
|
||||||
/>
|
/>
|
||||||
</PanelRow>
|
</PanelRow>
|
||||||
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
|
{/* M8: hint ไทยต้อง ≥11px + leading ไม่บีบ (สระซ้อนอ่านไม่ออกที่ 10px) */}
|
||||||
|
<p className="px-1 pt-1.5 text-[11px] leading-snug text-slate-500">
|
||||||
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
|
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
|
||||||
</p>
|
</p>
|
||||||
</div>,
|
</div>,
|
||||||
|
|
@ -3086,6 +3171,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === "shape"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={shapeMenuOpen}
|
aria-expanded={shapeMenuOpen}
|
||||||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||||
|
|
@ -3121,7 +3207,7 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
key={kind}
|
key={kind}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||||
shapeKind === kind
|
shapeKind === kind
|
||||||
? "bg-slate-100 text-slate-900"
|
? "bg-slate-100 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
|
|
@ -3162,6 +3248,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === "stamp"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={stampMenuOpen}
|
aria-expanded={stampMenuOpen}
|
||||||
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
|
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
|
||||||
|
|
@ -3197,9 +3284,11 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||||
|
// L1: รายการที่เลือก = state "active" ใช้ slate เหมือนเมนูอื่น
|
||||||
|
// (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
|
||||||
stampPreset === preset
|
stampPreset === preset
|
||||||
? "bg-red-50 text-red-700"
|
? "bg-slate-100 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStampPreset(preset)}
|
onClick={() => setStampPreset(preset)}
|
||||||
|
|
@ -3239,6 +3328,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === "textbox"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={textboxMenuOpen}
|
aria-expanded={textboxMenuOpen}
|
||||||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||||
|
|
@ -3302,7 +3392,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="สลับตัวหนา"
|
aria-label="สลับตัวหนา"
|
||||||
aria-pressed={textboxBold}
|
aria-pressed={textboxBold}
|
||||||
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${
|
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${FOCUS_RING_CLS} ${
|
||||||
textboxBold
|
textboxBold
|
||||||
? "border-primary bg-primary text-white"
|
? "border-primary bg-primary text-white"
|
||||||
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -3329,7 +3419,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`จัดแนว${alignLabel}`}
|
aria-label={`จัดแนว${alignLabel}`}
|
||||||
aria-pressed={textboxAlign === a}
|
aria-pressed={textboxAlign === a}
|
||||||
className={`px-2 py-1 transition ${
|
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
|
||||||
textboxAlign === a
|
textboxAlign === a
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-white"
|
||||||
: "text-slate-500 hover:bg-slate-100"
|
: "text-slate-500 hover:bg-slate-100"
|
||||||
|
|
@ -3354,6 +3444,7 @@ export default function PDFViewer({
|
||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={tool === key}
|
||||||
className={`px-2 py-2 transition ${corner} ${activeCls}`}
|
className={`px-2 py-2 transition ${corner} ${activeCls}`}
|
||||||
onClick={() => switchTool(key)}
|
onClick={() => switchTool(key)}
|
||||||
>
|
>
|
||||||
|
|
@ -3363,13 +3454,15 @@ export default function PDFViewer({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* F8: hint ความปลอดภัยของ redaction (PLAN-FEAT.md §5 — บนจอบังเท่านั้น) */}
|
{/* H3 (UX audit 2026-09-16): จอสัมผัสไม่มี hover อ่าน title ไม่ได้ — แสดงชื่อ tool
|
||||||
{tool === "redact" && (
|
ที่กำลังใช้เป็นข้อความถัดกลุ่มปุ่ม (จอ ≥ lg มี tooltip จาก title อยู่แล้ว)
|
||||||
<p className="hidden max-w-xs text-[11px] leading-tight text-amber-600 lg:block">
|
คำเตือน redaction ย้ายไปเป็นแถบเตือนใต้ header แสดงทุกขนาดจอแทน (H2) */}
|
||||||
เป็นการปกปิดข้อมูลบนหน้าจอชั่วคราวเท่านั้น (ข้อความดั้งเดิมยังสามารถคัดลอกได้)
|
<span
|
||||||
กรุณากด 'Export PDF' เพื่อปกปิดข้อมูลอย่างถาวรก่อนนำไปเผยแพร่
|
aria-hidden
|
||||||
</p>
|
className="hidden max-lg:block shrink-0 text-xs font-medium text-slate-600"
|
||||||
)}
|
>
|
||||||
|
{TOOLS.find((t) => t.key === tool)?.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
|
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
|
||||||
M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
|
M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
|
||||||
|
|
@ -3399,8 +3492,10 @@ export default function PDFViewer({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
|
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
|
||||||
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
|
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร)
|
||||||
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:shrink-0">
|
M2: จอ < lg ให้อยู่หัวแถวเคียง undo (bookmark เป็นส่วนของ flow หลัก
|
||||||
|
highlight → comment → bookmark แต่เดิมถูกดันไปท้ายสุดของแถบเลื่อน) */}
|
||||||
|
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:order-first max-lg:ml-0 max-lg:shrink-0">
|
||||||
<button
|
<button
|
||||||
ref={bookmarkBtnRef}
|
ref={bookmarkBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -3410,7 +3505,12 @@ export default function PDFViewer({
|
||||||
onClick={openBookmarkComposer}
|
onClick={openBookmarkComposer}
|
||||||
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||||
>
|
>
|
||||||
<BookmarkPlus className="h-4 w-4" />
|
{/* M6: ✓ เขียว 1.5 วิหลังบันทึกสำเร็จ — sidebar ปิดอยู่ก็รู้ว่าเซฟแล้ว */}
|
||||||
|
{bookmarkSaved ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<BookmarkPlus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{bookmarkComposer &&
|
{bookmarkComposer &&
|
||||||
bookmarkPos &&
|
bookmarkPos &&
|
||||||
|
|
@ -3434,7 +3534,7 @@ export default function PDFViewer({
|
||||||
</p>
|
</p>
|
||||||
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
|
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
|
||||||
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
|
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
|
||||||
<p className="mt-0.5 text-[11px] text-slate-400">
|
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||||
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
|
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
|
||||||
{bookmarkComposer.yOffset <= 25
|
{bookmarkComposer.yOffset <= 25
|
||||||
? "ส่วนบน"
|
? "ส่วนบน"
|
||||||
|
|
@ -3490,9 +3590,12 @@ export default function PDFViewer({
|
||||||
{optimisticBookmarks
|
{optimisticBookmarks
|
||||||
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
|
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
|
||||||
.filter((b) => bookmarkDepthOf(b) < 3)
|
.filter((b) => bookmarkDepthOf(b) < 3)
|
||||||
|
// M7: เรียงตามลำดับหน้าเหมือน sidebar (N5) + บอกเลขหน้าใน option
|
||||||
|
// ช่วยหาหัวข้อแม่บน select เนทีฟของ iPad ที่ไม่มีอย่างอื่นให้สแกน
|
||||||
|
.sort(byPageOrder)
|
||||||
.map((h) => (
|
.map((h) => (
|
||||||
<option key={h.id} value={h.id}>
|
<option key={h.id} value={h.id}>
|
||||||
ใต้ "{h.title}"
|
ใต้ "{h.title}" (หน้า {h.pageIndex + 1})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -3504,17 +3607,18 @@ export default function PDFViewer({
|
||||||
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
|
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100"
|
className={`rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => setBookmarkComposer(null)}
|
onClick={() => setBookmarkComposer(null)}
|
||||||
>
|
>
|
||||||
ยกเลิก
|
ยกเลิก
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85"
|
disabled={bookmarkSaving}
|
||||||
|
className={`rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85 disabled:opacity-60 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => void handleSaveBookmark()}
|
onClick={() => void handleSaveBookmark()}
|
||||||
>
|
>
|
||||||
บันทึก
|
{bookmarkSaving ? "กำลังบันทึก…" : "บันทึก"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|
@ -3585,6 +3689,23 @@ export default function PDFViewer({
|
||||||
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
|
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{/* H2 (UX audit 2026-09-16): คำเตือน redaction ย้ายมาเป็นแถบใต้ header แสดงทุก
|
||||||
|
ขนาดจอ — เดิมเป็น hidden lg:block ผู้ใช้ iPad portrait (อุปกรณ์หลัก) ไม่เห็น
|
||||||
|
เลย ทั้งเป็นความเสี่ยงข้อมูลส่วนบุคคลรั่วถ้าเผยแพร่ไฟล์ต้นฉบับโดยไม่ export */}
|
||||||
|
{tool === "redact" && (
|
||||||
|
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-700">
|
||||||
|
การปิดพรางบนหน้าจอเป็นการชั่วคราว — ข้อความเดิมยังคัดลอกได้ ต้องกด
|
||||||
|
"Export PDF" เพื่อปิดพรางถาวรก่อนนำไฟล์ไปเผยแพร่
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* M3: จอสัมผัส + tool highlight — อธิบายวิธีเลื่อนหน้า กันอาการ "เลื่อนไม่ได้
|
||||||
|
= แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */}
|
||||||
|
{tool === "highlight" && coarseTouch && coarseHlHintVisible && (
|
||||||
|
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
|
||||||
|
โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบบน/ล่างจอเพื่อเลื่อนหน้า
|
||||||
|
หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{pageSizes.length === 0 ? (
|
{pageSizes.length === 0 ? (
|
||||||
loadError ? (
|
loadError ? (
|
||||||
<div className="flex h-[70vh] items-center justify-center px-6">
|
<div className="flex h-[70vh] items-center justify-center px-6">
|
||||||
|
|
@ -3690,9 +3811,9 @@ export default function PDFViewer({
|
||||||
void handleSetResolved(id, resolved);
|
void handleSetResolved(id, resolved);
|
||||||
}}
|
}}
|
||||||
onAnnotationBookmark={handleAnnotationBookmark}
|
onAnnotationBookmark={handleAnnotationBookmark}
|
||||||
onCreateReply={(annotationId, message) => {
|
onCreateReply={(annotationId, message) =>
|
||||||
void handleCreateReply(annotationId, message);
|
handleCreateReply(annotationId, message)
|
||||||
}}
|
}
|
||||||
onDeleteReply={(id) => {
|
onDeleteReply={(id) => {
|
||||||
void handleDeleteReply(id);
|
void handleDeleteReply(id);
|
||||||
}}
|
}}
|
||||||
|
|
@ -3746,7 +3867,7 @@ export default function PDFViewer({
|
||||||
{hovered.userName}
|
{hovered.userName}
|
||||||
</p>
|
</p>
|
||||||
{note && (
|
{note && (
|
||||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-slate-500">
|
<p className="mt-0.5 whitespace-pre-wrap break-words leading-relaxed text-slate-500">
|
||||||
{note}
|
{note}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -3775,7 +3896,7 @@ export default function PDFViewer({
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
placeholder="พิมพ์ข้อความ comment"
|
placeholder="พิมพ์ข้อความ comment"
|
||||||
className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
className="w-full resize-none rounded border border-slate-300 p-2 text-xs leading-relaxed focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") setComposer(null);
|
if (e.key === "Escape") setComposer(null);
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
|
@ -3786,7 +3907,7 @@ export default function PDFViewer({
|
||||||
<div className="mt-2 flex justify-end gap-2 text-xs">
|
<div className="mt-2 flex justify-end gap-2 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
|
className={`coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => setComposer(null)}
|
onClick={() => setComposer(null)}
|
||||||
>
|
>
|
||||||
ยกเลิก
|
ยกเลิก
|
||||||
|
|
@ -3794,7 +3915,7 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim()}
|
||||||
className="coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
|
className={`coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => void handleComposerSave()}
|
onClick={() => void handleComposerSave()}
|
||||||
>
|
>
|
||||||
บันทึก
|
บันทึก
|
||||||
|
|
@ -3803,12 +3924,28 @@ export default function PDFViewer({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* F8: export พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI ของ rules/pdf-viewer.md) */}
|
{/* F8: export พัง + H1: action พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI
|
||||||
{exportError &&
|
ของ rules/pdf-viewer.md) วางซ้อนเป็นคอลัมน์เดียว ไม่ทับกันเมื่อเกิดพร้อมกัน */}
|
||||||
|
{(exportError || actionError) &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div className="fixed bottom-4 right-4 z-[60] max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl">
|
<div className="fixed bottom-4 right-4 z-[60] flex max-w-sm flex-col gap-2">
|
||||||
{exportError}
|
{actionError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl"
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exportError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl"
|
||||||
|
>
|
||||||
|
{exportError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ export type InkStamp = { id: string; userId: string; data: StampData };
|
||||||
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
|
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
|
||||||
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
|
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
|
||||||
const STAMP_FONT_STACK = "system-ui, sans-serif";
|
const STAMP_FONT_STACK = "system-ui, sans-serif";
|
||||||
|
|
||||||
|
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
|
||||||
|
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
|
||||||
|
* จะทำให้ตัดบรรทัดไม่เท่ากัน — ผูกเข้า webfont Sarabun (var จาก next/font) ให้ wrap
|
||||||
|
* ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */
|
||||||
|
const TEXTBOX_FONT_STACK =
|
||||||
|
"var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif";
|
||||||
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
|
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
|
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
|
||||||
|
|
@ -189,6 +196,7 @@ function TextboxEditor({
|
||||||
}}
|
}}
|
||||||
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"
|
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={{
|
style={{
|
||||||
|
fontFamily: TEXTBOX_FONT_STACK,
|
||||||
fontSize: toPx(fontSizePct, size.width),
|
fontSize: toPx(fontSizePct, size.width),
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
color,
|
color,
|
||||||
|
|
@ -275,6 +283,7 @@ export default function TextboxStampLayer({
|
||||||
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
||||||
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
||||||
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
||||||
|
fontFamily: TEXTBOX_FONT_STACK,
|
||||||
fontSize: toPx(t.data.fontSize, size.width),
|
fontSize: toPx(t.data.fontSize, size.width),
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
color: t.data.color,
|
color: t.data.color,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
|
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||||
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
|
import { ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
|
||||||
|
|
||||||
import type { HighlightStyle } from "@/db/schema";
|
import type { HighlightStyle } from "@/db/schema";
|
||||||
|
|
||||||
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
|
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
|
||||||
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
|
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
|
||||||
|
|
||||||
|
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
|
||||||
|
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
|
||||||
|
* อยู่แล้ว ห้ามวนกลับ) */
|
||||||
|
const FOCUS_RING_CLS =
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
|
||||||
|
|
||||||
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
|
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
|
||||||
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
|
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
|
||||||
export const INK_COLORS = [
|
export const INK_COLORS = [
|
||||||
|
|
@ -43,7 +49,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`เลือกสี ${c}`}
|
aria-label={`เลือกสี ${c}`}
|
||||||
title={c}
|
title={c}
|
||||||
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
|
className={`h-4 w-4 rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
|
||||||
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
|
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: c }}
|
style={{ backgroundColor: c }}
|
||||||
|
|
@ -52,7 +58,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
|
||||||
))}
|
))}
|
||||||
<label
|
<label
|
||||||
title="เลือกสีอื่น"
|
title="เลือกสีอื่น"
|
||||||
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
|
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
|
||||||
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
|
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -96,11 +102,9 @@ export function NumberCombo({
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
|
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
|
||||||
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
|
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
|
||||||
const [draft, setDraft] = useState(String(value));
|
const [draft, setDraft] = useState(String(value));
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => setDraft(String(value)), [value]);
|
useEffect(() => setDraft(String(value)), [value]);
|
||||||
|
|
||||||
|
|
@ -120,15 +124,6 @@ export function NumberCombo({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
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; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
|
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
|
||||||
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
|
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
|
||||||
function commitDraft() {
|
function commitDraft() {
|
||||||
|
|
@ -153,17 +148,7 @@ export function NumberCombo({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="number-combo">
|
||||||
ref={rootRef}
|
|
||||||
className="number-combo 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">
|
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -173,10 +158,7 @@ export function NumberCombo({
|
||||||
step={step}
|
step={step}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commitDraft}
|
onBlur={commitDraft}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => e.target.select()} // พิมพ์ทับค่าเดิมได้ทันที
|
||||||
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
|
|
||||||
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
||||||
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
|
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
|
||||||
|
|
@ -190,7 +172,7 @@ export function NumberCombo({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="เพิ่มค่า"
|
aria-label="เพิ่มค่า"
|
||||||
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
|
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
|
||||||
className="combo-step flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
|
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => stepValue(1)}
|
onClick={() => stepValue(1)}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
|
@ -199,42 +181,41 @@ export function NumberCombo({
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="ลดค่า"
|
aria-label="ลดค่า"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
className="combo-step flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
|
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => stepValue(-1)}
|
onClick={() => stepValue(-1)}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{/* L2 (UX audit 2026-09-16): preset เดิมเป็น dropdown เปิดลงจากช่อง — แถวท้าย
|
||||||
<div
|
ของเมนู tool (ความทึบ/ความหนา) ล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
|
||||||
role="listbox"
|
ครบ เปลี่ยนเป็น chips แถวเดียวใต้ช่อง: เห็นค่าที่เลือกได้ทันที ไม่มี popup
|
||||||
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"
|
ซ้อนในเมนูอีก (จอสัมผัสได้ min 36px จาก rule [data-tool-menu] โดยอัตโนมัติ) */}
|
||||||
>
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{options.map((opt) => (
|
{options.map((opt) => {
|
||||||
|
const active = value === opt;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={opt}
|
key={opt}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
aria-pressed={active}
|
||||||
aria-selected={value === opt}
|
className={`rounded border px-1.5 py-0.5 text-[11px] transition ${FOCUS_RING_CLS} ${
|
||||||
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
|
active
|
||||||
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
|
? "border-primary bg-primary/10 font-medium text-primary"
|
||||||
value === opt
|
: "border-slate-300 text-slate-500 hover:bg-slate-100"
|
||||||
? "bg-slate-100 text-slate-900"
|
|
||||||
: "text-slate-600 hover:bg-slate-100"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setDraft(String(opt));
|
||||||
onChange(opt);
|
onChange(opt);
|
||||||
setOpen(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt}
|
{opt}
|
||||||
{suffix}
|
{suffix}
|
||||||
{value === opt && <Check className="h-3 w-3 shrink-0" />}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,9 @@ function isDescendantOf(id: string, ancestorId: string, byId: Map<string, Bookma
|
||||||
}
|
}
|
||||||
|
|
||||||
/** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic)
|
/** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic)
|
||||||
* sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้ */
|
* sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้
|
||||||
function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
|
* (export ให้ PDFViewer ใช้กับ dropdown "วางที่" ของ popup bookmark ด้วย — M7 UX audit 2026-09-16) */
|
||||||
|
export function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
|
||||||
return (
|
return (
|
||||||
a.pageIndex - b.pageIndex ||
|
a.pageIndex - b.pageIndex ||
|
||||||
a.yOffset - b.yOffset ||
|
a.yOffset - b.yOffset ||
|
||||||
|
|
@ -747,14 +748,16 @@ export default function AnnotationSidebar({
|
||||||
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
|
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
|
||||||
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
|
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
|
||||||
const movableTo = editing
|
const movableTo = editing
|
||||||
? bookmarks.filter(
|
? bookmarks
|
||||||
(t) =>
|
.filter(
|
||||||
t.id !== b.id &&
|
(t) =>
|
||||||
!isDescendantOf(t.id, b.id, bookmarkById) &&
|
t.id !== b.id &&
|
||||||
bookmarkDepth(t, bookmarkById) +
|
!isDescendantOf(t.id, b.id, bookmarkById) &&
|
||||||
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
|
bookmarkDepth(t, bookmarkById) +
|
||||||
3,
|
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
|
||||||
)
|
3,
|
||||||
|
)
|
||||||
|
.sort(byPageOrder) // M7: เรียงตามลำดับหน้าเหมือนต้นไม้ — หาหัวข้อแม่ใน select ง่ายขึ้น
|
||||||
: [];
|
: [];
|
||||||
// พื้นหลัง hover อยู่ที่ div แถวรายการ ไม่ใช่ li — li ครอบ <ul> ลูกทั้งก้อน
|
// พื้นหลัง hover อยู่ที่ div แถวรายการ ไม่ใช่ li — li ครอบ <ul> ลูกทั้งก้อน
|
||||||
// ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว)
|
// ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว)
|
||||||
|
|
@ -802,7 +805,7 @@ export default function AnnotationSidebar({
|
||||||
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
||||||
{movableTo.map((h) => (
|
{movableTo.map((h) => (
|
||||||
<option key={h.id} value={h.id}>
|
<option key={h.id} value={h.id}>
|
||||||
ใต้ "{h.title}"
|
ใต้ "{h.title}" (หน้า {h.pageIndex + 1})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -855,12 +858,16 @@ export default function AnnotationSidebar({
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span
|
<span
|
||||||
className={`block truncate text-[13px] leading-snug ${
|
className={`block truncate text-[13px] leading-snug ${
|
||||||
depth === 1 ? "font-medium text-slate-700" : ""
|
depth === 1 ? "font-semibold text-slate-700" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{b.title}
|
{b.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-slate-500">หน้า {b.pageIndex + 1}</span>
|
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
|
||||||
|
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
|
||||||
|
</span>
|
||||||
{b.note && (
|
{b.note && (
|
||||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||||
{b.note}
|
{b.note}
|
||||||
|
|
@ -870,7 +877,7 @@ export default function AnnotationSidebar({
|
||||||
</button>
|
</button>
|
||||||
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
|
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] leading-4 text-slate-500">
|
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
|
||||||
{children.length}
|
{children.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1046,7 +1053,7 @@ export default function AnnotationSidebar({
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === key}
|
aria-selected={tab === key}
|
||||||
className={`flex-1 px-2 py-2 text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
|
||||||
tab === key
|
tab === key
|
||||||
? "border-b-2 border-primary text-slate-900"
|
? "border-b-2 border-primary text-slate-900"
|
||||||
: "text-slate-500 hover:text-slate-600"
|
: "text-slate-500 hover:text-slate-600"
|
||||||
|
|
@ -1252,7 +1259,7 @@ export default function AnnotationSidebar({
|
||||||
bmResults !== null ? (
|
bmResults !== null ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-1 pb-1">
|
<div className="flex items-center justify-between px-1 pb-1">
|
||||||
<p className="text-[11px] font-semibold text-slate-500">
|
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
||||||
ผลการค้นหา ({bmResults.length})
|
ผลการค้นหา ({bmResults.length})
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1358,6 +1365,11 @@ export default function AnnotationSidebar({
|
||||||
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
|
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
|
||||||
{b.title}
|
{b.title}
|
||||||
</span>
|
</span>
|
||||||
|
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
|
||||||
|
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
{b.userName || "ไม่ทราบชื่อ"}
|
||||||
|
</span>
|
||||||
{b.note && (
|
{b.note && (
|
||||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||||
{b.note}
|
{b.note}
|
||||||
|
|
@ -1365,7 +1377,7 @@ export default function AnnotationSidebar({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{childCount > 0 && (
|
{childCount > 0 && (
|
||||||
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] leading-4 text-slate-500">
|
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
|
||||||
{childCount}
|
{childCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1392,7 +1404,7 @@ export default function AnnotationSidebar({
|
||||||
) : visibleSearchResults !== null ? (
|
) : visibleSearchResults !== null ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-1 pb-1">
|
<div className="flex items-center justify-between px-1 pb-1">
|
||||||
<p className="text-[11px] font-semibold text-slate-500">
|
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
||||||
ผลการค้นหา ({visibleSearchResults.length})
|
ผลการค้นหา ({visibleSearchResults.length})
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1482,12 +1494,16 @@ export default function AnnotationSidebar({
|
||||||
<>
|
<>
|
||||||
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
|
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
|
||||||
{historyFor && (
|
{historyFor && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
|
||||||
|
onClick={() => setHistoryFor(null)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="ประวัติการเปลี่ยนแปลง"
|
aria-label="ประวัติการเปลี่ยนแปลง"
|
||||||
className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
||||||
<p className="mt-0.5 truncate text-[11px] text-slate-500">
|
<p className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||||
|
|
@ -1574,14 +1590,14 @@ export default function AnnotationSidebar({
|
||||||
<div className="mt-1.5 flex justify-end gap-1.5">
|
<div className="mt-1.5 flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||||
onClick={closeRowMenu}
|
onClick={closeRowMenu}
|
||||||
>
|
>
|
||||||
ยกเลิก
|
ยกเลิก
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded bg-red-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
|
className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
|
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
|
||||||
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
|
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue