From 21b9958600e6782cd9db7dfe14c2db25557d9b92 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 14:28:53 +0700 Subject: [PATCH] =?UTF-8?q?UX=20audit=20=E0=B8=97=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=E0=B9=81=E0=B8=AD=E0=B8=9B=20+=20typography=20audit?= =?UTF-8?q?=202026-09-16:=20toast=20=E0=B9=81=E0=B8=88=E0=B9=89=E0=B8=87?= =?UTF-8?q?=20error=20=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81=20(H1=20=E2=80=94=20?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=E0=B8=A3=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=AB=E0=B8=B2=E0=B8=A2=E0=B9=80?= =?UTF-8?q?=E0=B8=87=E0=B8=B5=E0=B8=A2=E0=B8=9A=20+=20ReplyBox=20=E0=B9=80?= =?UTF-8?q?=E0=B8=81=E0=B9=87=E0=B8=9A=20draft=20=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=20fail),=20=E0=B8=84=E0=B8=B3=E0=B9=80=E0=B8=95?= =?UTF-8?q?=E0=B8=B7=E0=B8=AD=E0=B8=99=20redaction=20=E0=B9=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=94=E0=B8=87=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=82=E0=B8=99?= =?UTF-8?q?=E0=B8=B2=E0=B8=94=E0=B8=88=E0=B8=AD=20(H2),=20toolbar=20?= =?UTF-8?q?=E0=B9=81=E0=B8=AA=E0=B8=94=E0=B8=87=E0=B8=8A=E0=B8=B7=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=20tool=20active=20+=20aria-pressed=20=E0=B8=9A?= =?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=AD=E0=B8=AA=E0=B8=B1=E0=B8=A1=E0=B8=9C?= =?UTF-8?q?=E0=B8=B1=E0=B8=AA=20(H3/M9),=20=E0=B8=9B=E0=B8=B8=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=20popover=20=E0=B8=88=E0=B8=B4=E0=B9=89=E0=B8=A1?= =?UTF-8?q?=E0=B8=87=E0=B9=88=E0=B8=B2=E0=B8=A2=2036px=20+=20focus=20ring?= =?UTF-8?q?=20=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=9A=20(H4)=20+=20M1-M9=20(=E0=B8=9B=E0=B8=B4=E0=B8=94=20s?= =?UTF-8?q?idebar=20=E0=B8=95=E0=B8=AD=E0=B8=99=20navigate,=20bookmark=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=B1=E0=B8=A7=E0=B9=81=E0=B8=96=E0=B8=A7=E0=B8=88?= =?UTF-8?q?=E0=B8=AD=E0=B9=80=E0=B8=A5=E0=B9=87=E0=B8=81,=20hint=20?= =?UTF-8?q?=E0=B9=82=E0=B8=AB=E0=B8=A1=E0=B8=94=E0=B8=A1=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B9=8C=E0=B8=81=E0=B8=9A=E0=B8=99=E0=B8=88=E0=B8=AD=E0=B8=AA?= =?UTF-8?q?=E0=B8=B1=E0=B8=A1=E0=B8=9C=E0=B8=B1=E0=B8=AA,=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B9=8C=E0=B8=94=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B8=84=E0=B8=A5=E0=B8=B4=E0=B8=81=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=83=E0=B8=9A,=20?= =?UTF-8?q?=E0=B9=80=E0=B8=8A=E0=B9=87=E0=B8=84=20100MB=20=E0=B8=9D?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=87=20client,=20feedback=20=E2=9C=93/pe?= =?UTF-8?q?nding=20=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B8=9A=E0=B8=B1=E0=B8=99?= =?UTF-8?q?=E0=B8=97=E0=B8=B6=E0=B8=81=20bookmark,=20dropdown=20=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B8=97=E0=B8=B5=E0=B9=88=20sort=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2?= =?UTF-8?q?+=E0=B9=80=E0=B8=A5=E0=B8=82=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2,=20slate-500=20=E0=B9=81=E0=B8=97=E0=B8=99=20slate-40?= =?UTF-8?q?0)=20+=20webfont=20Sarabun=20=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=20next/font=20self-host=20400/600=20(Option=20B=20?= =?UTF-8?q?=E2=80=94=20=E0=B9=81=E0=B8=AA=E0=B8=94=E0=B8=87=E0=B8=9C?= =?UTF-8?q?=E0=B8=A5=E0=B8=95=E0=B8=A3=E0=B8=87=E0=B8=81=E0=B8=B1=E0=B8=99?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=AD=E0=B8=B8=E0=B8=9B=E0=B8=81?= =?UTF-8?q?=E0=B8=A3=E0=B8=93=E0=B9=8C=20=E0=B8=A3=E0=B8=A7=E0=B8=A1=20TEX?= =?UTF-8?q?TBOX=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=9C=E0=B8=B9=E0=B8=81?= =?UTF-8?q?=20stack=20=E0=B8=99=E0=B8=B5=E0=B9=89)=20+=20NumberCombo=20pre?= =?UTF-8?q?set=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20chips=20=E0=B9=81?= =?UTF-8?q?=E0=B8=97=E0=B8=99=20dropdown=20=E0=B8=A5=E0=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B9=80=E0=B8=A1=E0=B8=99=E0=B8=B9=20(L2)=20+=20leading-relax?= =?UTF-8?q?ed/13px=20=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=97=E0=B8=A2=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=20wrap=20+=20tabular-nums=20+=20prefers-reduced-motio?= =?UTF-8?q?n=20(L7)=20+=20L1/L4/L5=20+=20=E0=B9=81=E0=B8=96=E0=B8=A7=20boo?= =?UTF-8?q?kmark=20=E0=B9=81=E0=B8=AA=E0=B8=94=E0=B8=87=E0=B8=8A=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81=E0=B9=80=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B8=9C=E0=B8=A5=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=84=E0=B9=89=E0=B8=99=E0=B8=AB=E0=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/annotate/[id]/summary/page.tsx | 6 +- app/globals.css | 40 +++- app/layout.tsx | 16 +- app/page.tsx | 20 +- components/UploadFileForm.tsx | 15 +- components/pdf/AnnotationSelectionLayer.tsx | 17 +- components/pdf/PDFViewer.tsx | 247 +++++++++++++++----- components/pdf/TextboxStampLayer.tsx | 9 + components/pdf/ToolControls.tsx | 81 +++---- components/sidebar/AnnotationSidebar.tsx | 58 +++-- 10 files changed, 351 insertions(+), 158 deletions(-) diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index 3275e23..a0a881b 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -84,17 +84,17 @@ export default async function SummaryPage({ {annotationTypeLabel(a)} {a.type === "COMMENT" && a.resolved && ( - + จัดการแล้ว )} {a.userName} - + {annotationPreviewText(a)} {annotationNote(a) && ( - + โน้ต: {annotationNote(a)} )} diff --git a/app/globals.css b/app/globals.css index 2c98816..0673981 100644 --- a/app/globals.css +++ b/app/globals.css @@ -108,10 +108,11 @@ /* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼ - จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถวเองที่ 36px แทน: - แตะ input = เปิด preset list (แถวละ 36px จาก rule ด้านบน) คือ touch path - หลัก ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */ - [data-tool-menu] .number-combo > div { + จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px + แทน (:first-child — แถว chips preset ของ L2 โดน rule ปุ่ม 36px ของเมนู + ครอบอยู่แล้ว ห้ามบังคับสูงซ้ำ): แตะ chips preset = touch path หลัก + ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */ + [data-tool-menu] .number-combo > div:first-child { height: 2.25rem; } [data-tool-menu] .combo-step { @@ -197,14 +198,16 @@ } } -/* UX review 2026-09-14 H2: แอปไม่เคยตั้ง font-family เลย ข้อความไทยเลยตกไปที่ - default ของ OS แต่ละเครื่อง — วางฟอนต์ไทยนำหน้า stack (ไม่โหลด webfont - ตามที่เลือกไว้: Leelawadee UI = Windows, Noto Sans Thai = Android/Linux, - Thonburi = macOS) */ +/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont + Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font + ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback + ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1: + Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI + (Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */ body { - font-family: "Leelawadee UI", "Noto Sans Thai", "Thonburi", - ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; + font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI", + "Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai", + sans-serif; } /* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน @@ -249,3 +252,18 @@ header { .comment-composer { 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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 258866a..7a6a612 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,21 @@ import type { Metadata } from "next"; +import { Sarabun } from "next/font/google"; 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 = { title: "Judge's Note", description: "ระบบ PDF Annotation สำหรับ e-Court", @@ -13,7 +27,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/app/page.tsx b/app/page.tsx index b607cc8..72f099c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { asc } from "drizzle-orm"; -import { FileImage, FileText } from "lucide-react"; +import { ChevronRight, FileImage, FileText } from "lucide-react"; import { db } from "@/db"; import { files } from "@/db/schema"; @@ -48,7 +48,7 @@ export default async function HomePage() {

เอกสาร

{rows.length > 0 && ( - {rows.length} ไฟล์ + {rows.length} ไฟล์ )}
@@ -59,7 +59,7 @@ export default async function HomePage() {

ยังไม่มีไฟล์ในระบบ

-

+

รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง

@@ -68,7 +68,7 @@ export default async function HomePage() { {rows.map((f) => (
  • {imageFormatFromUrl(f.pdfUrl) ? ( @@ -81,19 +81,21 @@ export default async function HomePage() { {f.title} - + {kindBadge(f.pdfUrl)} {new Date(f.createdAt).toLocaleString("th-TH")} + + {/* M4 (UX audit 2026-09-16): ลิงก์กางเต็มการ์ด — จอสัมผัสจิ้มตรงไหนก็เข้าได้ + (เดิมปุ่ม "Annotate" ~30px อย่างเดียว และเป็นภาษาอังกฤษใน UI ไทย) */} - Annotate - + 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}`} + />
  • ))} diff --git a/components/UploadFileForm.tsx b/components/UploadFileForm.tsx index 14dc44a..ed5efab 100644 --- a/components/UploadFileForm.tsx +++ b/components/UploadFileForm.tsx @@ -8,6 +8,11 @@ import { uploadFile } from "@/app/actions/files"; 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() { const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); @@ -16,6 +21,12 @@ export default function UploadFileForm() { e.preventDefault(); const form = e.currentTarget; 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 () => { try { const result = await uploadFile(new FormData(form)); @@ -42,12 +53,12 @@ export default function UploadFileForm() { accept={ACCEPT} required 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" /> diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index 0d4e9c4..1035ab7 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -25,6 +25,11 @@ import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/ut import { STAMP_COLOR } from "@/utils/annotation-display"; 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) // - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ** // เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto @@ -519,7 +524,7 @@ export default function AnnotationSelectionLayer({ aria-label={label} title={label} 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 ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100" @@ -589,7 +594,7 @@ export default function AnnotationSelectionLayer({ @@ -638,7 +642,7 @@ function PdfPage({ onSetResolved: (id: string, resolved: boolean) => void; /** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */ onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void; - onCreateReply: (annotationId: string, message: string) => void; + onCreateReply: (annotationId: string, message: string) => Promise; onDeleteReply: (id: string) => void; onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void; onShapeEnd: (data: ShapeData) => void; @@ -963,6 +967,9 @@ function PdfPage({ const x = toPx(a.data.position.x, width); const y = toPx(a.data.position.y, height); 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 (
    ) : ( -

    {a.data.message}

    + // T2/T3 (typography audit): ข้อความหลักไทยที่อ่านต่อเนื่อง = 13px + + // leading-relaxed (สระบน+วรรณยุกต์ซ้อนชนกันที่ leading เดิม) +

    + {a.data.message} +

    )}

    {new Date(a.createdAt).toLocaleString("th-TH")} @@ -1063,7 +1079,7 @@ function PdfPage({ {/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */} - + {/* L4: เลข 3 หลักล้น w-16 — ปล่อยกว้างตามเนื้อหา มีขั้นต่ำให้ตัวเลขไม่ขยับ */} + หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length} {bookmarkComposer && bookmarkPos && @@ -3434,7 +3534,7 @@ export default function PDFViewer({

    {/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup) กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */} -

    +

    จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "} {bookmarkComposer.yOffset <= 25 ? "ส่วนบน" @@ -3490,9 +3590,12 @@ export default function PDFViewer({ {optimisticBookmarks // เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ) .filter((b) => bookmarkDepthOf(b) < 3) + // M7: เรียงตามลำดับหน้าเหมือน sidebar (N5) + บอกเลขหน้าใน option + // ช่วยหาหัวข้อแม่บน select เนทีฟของ iPad ที่ไม่มีอย่างอื่นให้สแกน + .sort(byPageOrder) .map((h) => ( ))} @@ -3504,17 +3607,18 @@ export default function PDFViewer({

    , @@ -3585,6 +3689,23 @@ export default function PDFViewer({ หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้

    )} + {/* H2 (UX audit 2026-09-16): คำเตือน redaction ย้ายมาเป็นแถบใต้ header แสดงทุก + ขนาดจอ — เดิมเป็น hidden lg:block ผู้ใช้ iPad portrait (อุปกรณ์หลัก) ไม่เห็น + เลย ทั้งเป็นความเสี่ยงข้อมูลส่วนบุคคลรั่วถ้าเผยแพร่ไฟล์ต้นฉบับโดยไม่ export */} + {tool === "redact" && ( +

    + การปิดพรางบนหน้าจอเป็นการชั่วคราว — ข้อความเดิมยังคัดลอกได้ ต้องกด + "Export PDF" เพื่อปิดพรางถาวรก่อนนำไฟล์ไปเผยแพร่ +

    + )} + {/* M3: จอสัมผัส + tool highlight — อธิบายวิธีเลื่อนหน้า กันอาการ "เลื่อนไม่ได้ + = แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */} + {tool === "highlight" && coarseTouch && coarseHlHintVisible && ( +

    + โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบบน/ล่างจอเพื่อเลื่อนหน้า + หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ +

    + )} {pageSizes.length === 0 ? ( loadError ? (
    @@ -3690,9 +3811,9 @@ export default function PDFViewer({ void handleSetResolved(id, resolved); }} onAnnotationBookmark={handleAnnotationBookmark} - onCreateReply={(annotationId, message) => { - void handleCreateReply(annotationId, message); - }} + onCreateReply={(annotationId, message) => + handleCreateReply(annotationId, message) + } onDeleteReply={(id) => { void handleDeleteReply(id); }} @@ -3746,7 +3867,7 @@ export default function PDFViewer({ {hovered.userName}

    {note && ( -

    +

    {note}

    )} @@ -3775,7 +3896,7 @@ export default function PDFViewer({ value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="พิมพ์ข้อความ comment" - className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400" + 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) => { if (e.key === "Escape") setComposer(null); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { @@ -3786,7 +3907,7 @@ export default function PDFViewer({
    )} - {/* F8: export พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI ของ rules/pdf-viewer.md) */} - {exportError && + {/* F8: export พัง + H1: action พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI + ของ rules/pdf-viewer.md) วางซ้อนเป็นคอลัมน์เดียว ไม่ทับกันเมื่อเกิดพร้อมกัน */} + {(exportError || actionError) && typeof document !== "undefined" && createPortal( -
    - {exportError} +
    + {actionError && ( +
    + {actionError} +
    + )} + {exportError && ( +
    + {exportError} +
    + )}
    , document.body, )} diff --git a/components/pdf/TextboxStampLayer.tsx b/components/pdf/TextboxStampLayer.tsx index 52f20af..e87b692 100644 --- a/components/pdf/TextboxStampLayer.tsx +++ b/components/pdf/TextboxStampLayer.tsx @@ -23,6 +23,13 @@ export type InkStamp = { id: string; userId: string; data: StampData }; // ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง // ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold) 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; /** วัดความกว้างข้อความตรายางบนจอ — ไม่มี 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" style={{ + fontFamily: TEXTBOX_FONT_STACK, fontSize: toPx(fontSizePct, size.width), lineHeight: 1.5, color, @@ -275,6 +283,7 @@ export default function TextboxStampLayer({ // (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม ...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null), maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`, + fontFamily: TEXTBOX_FONT_STACK, fontSize: toPx(t.data.fontSize, size.width), lineHeight: 1.5, color: t.data.color, diff --git a/components/pdf/ToolControls.tsx b/components/pdf/ToolControls.tsx index 2008b0b..b052441 100644 --- a/components/pdf/ToolControls.tsx +++ b/components/pdf/ToolControls.tsx @@ -1,13 +1,19 @@ "use client"; -import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react"; -import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react"; +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react"; import type { HighlightStyle } from "@/db/schema"; // ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ // บนกรอบ 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) * สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */ export const INK_COLORS = [ @@ -43,7 +49,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c type="button" aria-label={`เลือกสี ${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" }`} style={{ backgroundColor: c }} @@ -52,7 +58,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c ))}