Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowRight,
|
|
|
|
|
|
Bookmark,
|
2026-09-17 13:43:00 +07:00
|
|
|
|
Braces,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
Check,
|
|
|
|
|
|
ChevronDown,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Circle,
|
|
|
|
|
|
Clock,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
EyeOff,
|
|
|
|
|
|
Highlighter,
|
|
|
|
|
|
MessageSquare,
|
|
|
|
|
|
Minus,
|
2026-09-16 13:24:33 +07:00
|
|
|
|
MoreHorizontal,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
Pencil,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Search,
|
2026-09-16 13:24:33 +07:00
|
|
|
|
SlidersHorizontal,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
Square,
|
|
|
|
|
|
Stamp,
|
|
|
|
|
|
StickyNote,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Type,
|
|
|
|
|
|
Underline,
|
|
|
|
|
|
Strikethrough,
|
|
|
|
|
|
X,
|
2026-09-16 13:24:33 +07:00
|
|
|
|
type LucideIcon,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
|
2026-09-10 18:02:01 +07:00
|
|
|
|
import Select from "../Select";
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
import {
|
|
|
|
|
|
listVersions,
|
|
|
|
|
|
restoreVersion,
|
|
|
|
|
|
searchAnnotations,
|
2026-09-16 13:24:33 +07:00
|
|
|
|
searchBookmarks,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
} from "@/app/actions/annotations";
|
|
|
|
|
|
import type { AnnotationVersionDTO } from "@/db/schema";
|
|
|
|
|
|
import type {
|
|
|
|
|
|
AnnotationData,
|
|
|
|
|
|
AnnotationDTO,
|
|
|
|
|
|
BookmarkDTO,
|
|
|
|
|
|
CommentData,
|
|
|
|
|
|
DrawData,
|
|
|
|
|
|
HighlightData,
|
|
|
|
|
|
RedactionData,
|
|
|
|
|
|
ShapeData,
|
|
|
|
|
|
} from "@/db/schema";
|
|
|
|
|
|
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
|
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
|
|
|
|
import { withBasePath } from "@/utils/base-path";
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
/** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS
|
|
|
|
|
|
* ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น
|
|
|
|
|
|
* circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */
|
|
|
|
|
|
const FOCUS_RING_CLS =
|
|
|
|
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
const cls = "h-4 w-4 shrink-0 text-slate-500";
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
if (type === "HIGHLIGHT") {
|
|
|
|
|
|
const style = (data as HighlightData).style ?? "highlight";
|
|
|
|
|
|
if (style === "underline") return <Underline className={cls} />;
|
|
|
|
|
|
if (style === "strikethrough") return <Strikethrough className={cls} />;
|
|
|
|
|
|
return <Highlighter className={cls} />; // highlight / squiggle
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === "DRAW")
|
|
|
|
|
|
return (data as DrawData).pen === "highlighter" ? (
|
|
|
|
|
|
<Highlighter className={cls} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Pencil className={cls} />
|
|
|
|
|
|
);
|
|
|
|
|
|
if (type === "REDACTION") return <EyeOff className={cls} />;
|
|
|
|
|
|
if (type === "TEXTBOX") return <Type className={cls} />;
|
|
|
|
|
|
if (type === "STAMP") return <Stamp className={cls} />;
|
|
|
|
|
|
if (type === "SHAPE") {
|
|
|
|
|
|
const shape = (data as ShapeData).shape;
|
|
|
|
|
|
if (shape === "rect") return <Square className={cls} />;
|
|
|
|
|
|
if (shape === "ellipse") return <Circle className={cls} />;
|
|
|
|
|
|
if (shape === "line") return <Minus className={cls} />;
|
|
|
|
|
|
return <ArrowRight className={cls} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
return <MessageSquare className={cls} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
|
|
|
|
|
|
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
|
|
|
|
|
|
return note ? { ...data, note } : { ...data, note: undefined };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── N2: helper โครงสร้างต้นไม้ bookmark (3 ระดับ) — คำนวณบน state ที่โหลดครบ ────
|
|
|
|
|
|
// (ปกติลึกสุด 3 — cap กันลูปไม่รู้จบหากข้อมูลวนซ้ำ)
|
|
|
|
|
|
|
|
|
|
|
|
/** ความลึกของ bookmark (root = 1) — เดินขึ้นตาม parentId (parent หาย = ถือเป็น root) */
|
|
|
|
|
|
function bookmarkDepth(b: BookmarkDTO, byId: Map<string, BookmarkDTO>): number {
|
|
|
|
|
|
let depth = 1;
|
|
|
|
|
|
let cur: BookmarkDTO | undefined = b;
|
|
|
|
|
|
while (cur?.parentId && depth <= 100) {
|
|
|
|
|
|
const parent = byId.get(cur.parentId);
|
|
|
|
|
|
if (!parent) break;
|
|
|
|
|
|
depth += 1;
|
|
|
|
|
|
cur = parent;
|
|
|
|
|
|
}
|
|
|
|
|
|
return depth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) */
|
|
|
|
|
|
function subtreeHeight(b: BookmarkDTO, kidsOf: (id: string) => BookmarkDTO[], level = 1): number {
|
|
|
|
|
|
if (level > 100) return 1; // กันข้อมูลวนซ้ำ
|
|
|
|
|
|
const kids = kidsOf(b.id);
|
|
|
|
|
|
return kids.length === 0
|
|
|
|
|
|
? 1
|
|
|
|
|
|
: 1 + Math.max(...kids.map((k) => subtreeHeight(k, kidsOf, level + 1)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) */
|
|
|
|
|
|
function isDescendantOf(id: string, ancestorId: string, byId: Map<string, BookmarkDTO>): boolean {
|
|
|
|
|
|
let cur = byId.get(id);
|
|
|
|
|
|
for (let steps = 0; cur?.parentId && steps <= 100; steps += 1) {
|
|
|
|
|
|
if (cur.parentId === ancestorId) return true;
|
|
|
|
|
|
cur = byId.get(cur.parentId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
/** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic)
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
* sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้
|
|
|
|
|
|
* (export ให้ PDFViewer ใช้กับ dropdown "วางที่" ของ popup bookmark ด้วย — M7 UX audit 2026-09-16) */
|
|
|
|
|
|
export function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
|
2026-09-16 13:24:33 +07:00
|
|
|
|
return (
|
|
|
|
|
|
a.pageIndex - b.pageIndex ||
|
|
|
|
|
|
a.yOffset - b.yOffset ||
|
|
|
|
|
|
a.createdAt.localeCompare(b.createdAt)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── เมนู ⋯ ของแถวรายการ — ปุ่ม action เดียวแทนไอคอนชุดติดกัน ──────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** ตำแหน่งเมนู ⋯ — คัดลอก pattern fixedMenuPos ของ PDFViewer.tsx (import ข้ามกัน
|
|
|
|
|
|
* ไม่ได้เพราะ circular): ชิดขอบขวาปุ่ม ⋯ (ปุ่มอยู่ขวาสุดแถว) แล้ว clamp ไม่ให้ล้นจอ */
|
|
|
|
|
|
const ROW_MENU_W = 240; // w-60 เท่า dropdown ของ tool
|
|
|
|
|
|
function rowMenuPos(rect: DOMRect): { left: number; top: number } {
|
|
|
|
|
|
const vw = window.innerWidth;
|
|
|
|
|
|
return {
|
|
|
|
|
|
left: Math.min(Math.max(rect.right - ROW_MENU_W, 8), Math.max(8, vw - ROW_MENU_W - 8)),
|
|
|
|
|
|
top: rect.bottom + 4,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** เป้าหมายของเมนู ⋯ ที่เปิดอยู่ — เก็บแค่ kind+id ไม่เก็บ object เพราะ props
|
|
|
|
|
|
* annotations/bookmarks เปลี่ยนตลอดจาก SSE (realtime) — lookup ตอน render/คลิก
|
|
|
|
|
|
* ให้ได้ข้อมูลสดเสมอ ไม่มี ghost object */
|
|
|
|
|
|
type RowMenuTarget = { kind: "annotation" | "bookmark"; id: string };
|
|
|
|
|
|
|
|
|
|
|
|
// ─── มุมมอง "ตามหน้า" — สลับมุมมองรองในแท็บ + หัวกลุ่มหน้าที่หุบได้ ────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** ปุ่มสลับมุมมองภายในแท็บ (ตามหัวข้อ↔ตามหน้า / ตามสถานะ↔ตามหน้า) —
|
|
|
|
|
|
* segmented control ระดับรองใต้ tab หลัก (tab ใช้เส้นใต้ อันนี้ใช้ pill) */
|
|
|
|
|
|
function ViewSwitch<T extends string>({
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: T;
|
|
|
|
|
|
options: { key: T; label: string }[];
|
|
|
|
|
|
onChange: (v: T) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex rounded-md bg-slate-100 p-0.5">
|
|
|
|
|
|
{options.map((o) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={o.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-pressed={value === o.key}
|
|
|
|
|
|
className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${
|
|
|
|
|
|
value === o.key
|
|
|
|
|
|
? "bg-white text-slate-900 shadow-sm"
|
|
|
|
|
|
: "text-slate-500 hover:text-slate-600"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => onChange(o.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** รายการในเมนู ⋯ — ไอคอนเล็ก + label ไทย (จอสัมผัสไม่มี hover อ่าน title ไม่ได้
|
|
|
|
|
|
* label จึงต้องอยู่ในตัวเมนู) / danger = โซนทำลาย (แดง) / items-start รองรับ
|
|
|
|
|
|
* label ยาวอย่าง "เพิ่มหน้าที่ดูอยู่เข้าหัวข้อนี้" ที่ wrap 2 บรรทัดใน w-60 */
|
|
|
|
|
|
function MenuItem({
|
|
|
|
|
|
Icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
danger,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
Icon: LucideIcon;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
danger?: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${
|
|
|
|
|
|
danger ? "text-red-600 hover:bg-red-50" : "text-slate-600 hover:bg-slate-100"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
|
|
|
|
<span className="flex-1">{label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** หัวกลุ่ม "หน้า N (จำนวน)" — หุบ/กางรายการของหน้านั้น (chevron เหมือนต้นไม้ bookmark) */
|
|
|
|
|
|
function PageGroupHeader({
|
|
|
|
|
|
pageIndex,
|
|
|
|
|
|
count,
|
|
|
|
|
|
collapsed,
|
|
|
|
|
|
onToggle,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
pageIndex: number;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
collapsed: boolean;
|
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`}
|
|
|
|
|
|
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
|
>
|
|
|
|
|
|
{collapsed ? (
|
|
|
|
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-xs font-medium text-slate-700">
|
|
|
|
|
|
หน้า {pageIndex + 1}
|
|
|
|
|
|
<span className="ml-1 font-normal text-slate-500">({count})</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** จัดกลุ่มตามหน้าแล้วเรียง pageIndex น้อย→มาก — ลำดับในกลุ่มตาม input ต่อเนื่อง */
|
|
|
|
|
|
function groupByPage<T extends { pageIndex: number }>(items: T[]): [number, T[]][] {
|
|
|
|
|
|
const byPage = new Map<number, T[]>();
|
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
|
const list = byPage.get(it.pageIndex);
|
|
|
|
|
|
if (list) list.push(it);
|
|
|
|
|
|
else byPage.set(it.pageIndex, [it]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(byPage).sort(([a], [b]) => a - b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── N6: เงื่อนไขการค้นหา bookmark (คำค้นอยู่แยกใน state ของฟอร์ม) ────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** ค่าทุกช่องเก็บเป็น string ตรง ๆ จาก input (ว่าง = ไม่ตั้งเงื่อนไข) —
|
|
|
|
|
|
* แปลงเป็นตัวเลข/ส่งต่อ server ตอนกดค้นเท่านั้น */
|
|
|
|
|
|
type BmConditions = {
|
|
|
|
|
|
pageFrom: string;
|
|
|
|
|
|
pageTo: string;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
dateFrom: string;
|
|
|
|
|
|
dateTo: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_BM_CONDITIONS: BmConditions = {
|
|
|
|
|
|
pageFrom: "",
|
|
|
|
|
|
pageTo: "",
|
|
|
|
|
|
userId: "",
|
|
|
|
|
|
dateFrom: "",
|
|
|
|
|
|
dateTo: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** วันที่ ISO (yyyy-mm-dd) → d/m/พ.ศ. — เจ้าหน้าที่ศาลคิดวันที่เป็น พ.ศ. */
|
|
|
|
|
|
function fmtThaiDate(iso: string): string {
|
|
|
|
|
|
const [y, m, d] = iso.split("-").map(Number);
|
|
|
|
|
|
return `${d}/${m}/${y + 543}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** สรุปเงื่อนไขค้นหา bookmark เป็นข้อความสั้น — แถวสรุปใต้ช่องค้นหาใช้แบบไม่มีคำค้น
|
|
|
|
|
|
* (คำค้นแสดงอยู่ใน input ข้างบนแล้ว), หัวผลการค้นหาใช้แบบมีคำค้น (snapshot ตอนกดหา)
|
|
|
|
|
|
* ช่วงที่กรองฝั่งเดียวแสดงปลายเปิด เช่น "หน้า 5–…" */
|
|
|
|
|
|
function bmConditionSummary(c: BmConditions, q?: string, userName?: string): string {
|
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
if (q?.trim()) parts.push(`"${q.trim()}"`);
|
|
|
|
|
|
if (c.pageFrom || c.pageTo) parts.push(`หน้า ${c.pageFrom || "…"}–${c.pageTo || "…"}`);
|
|
|
|
|
|
if (c.userId) parts.push(userName ?? "ผู้บันทึกที่เลือก");
|
|
|
|
|
|
if (c.dateFrom || c.dateTo)
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`${c.dateFrom ? fmtThaiDate(c.dateFrom) : "…"}–${c.dateTo ? fmtThaiDate(c.dateTo) : "…"}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
return parts.join(" · ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ค่าจาก input number → เงื่อนไขช่วงหน้า (1-based ตรงที่ผู้ใช้เห็น) —
|
|
|
|
|
|
* ว่าง/ไม่ใช่จำนวนเต็ม ≥ 1 = ไม่ส่งเงื่อนไขนั้น */
|
|
|
|
|
|
function toPageCondition(v: string): number | undefined {
|
|
|
|
|
|
if (!v) return undefined;
|
|
|
|
|
|
const n = Number(v);
|
|
|
|
|
|
return Number.isInteger(n) && n >= 1 ? n : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
/** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */
|
|
|
|
|
|
export default function AnnotationSidebar({
|
|
|
|
|
|
annotations,
|
|
|
|
|
|
bookmarks,
|
|
|
|
|
|
fileId,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
userFilter,
|
|
|
|
|
|
users,
|
|
|
|
|
|
onUserFilterChange,
|
|
|
|
|
|
replyCounts,
|
|
|
|
|
|
onDeleteAnnotation,
|
|
|
|
|
|
onUpdateAnnotation,
|
|
|
|
|
|
onDeleteBookmark,
|
|
|
|
|
|
onAddSubBookmark,
|
|
|
|
|
|
onUpdateBookmark,
|
|
|
|
|
|
onGoToBookmark,
|
|
|
|
|
|
onGoToAnnotation,
|
2026-09-14 11:35:05 +07:00
|
|
|
|
selectedId,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
}: {
|
|
|
|
|
|
annotations: AnnotationDTO[];
|
|
|
|
|
|
bookmarks: BookmarkDTO[];
|
|
|
|
|
|
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
|
|
|
|
|
|
fileId: string;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
|
|
|
|
|
|
userFilter: string;
|
|
|
|
|
|
users: { userId: string; userName: string }[];
|
|
|
|
|
|
onUserFilterChange: (value: string) => void;
|
|
|
|
|
|
/** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */
|
|
|
|
|
|
replyCounts: Record<string, number>;
|
|
|
|
|
|
onDeleteAnnotation: (id: string) => void;
|
|
|
|
|
|
/** แก้ไข data ของรายการของตัวเอง (โน้ตคำอธิบาย) — parent ทำ optimistic + updateAnnotation */
|
|
|
|
|
|
onUpdateAnnotation: (id: string, data: AnnotationData) => void;
|
|
|
|
|
|
onDeleteBookmark: (id: string) => void;
|
|
|
|
|
|
/** N2: ปุ่ม + ที่หัวข้อ — เพิ่ม bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนั้นทันที */
|
|
|
|
|
|
onAddSubBookmark: (parentId: string) => void;
|
|
|
|
|
|
/** N2: แก้ bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (patch เฉพาะ field ที่เปลี่ยน) */
|
|
|
|
|
|
onUpdateBookmark: (
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
patch: { title?: string; note?: string | null; parentId?: string | null },
|
|
|
|
|
|
) => void;
|
|
|
|
|
|
onGoToBookmark: (b: BookmarkDTO) => void;
|
|
|
|
|
|
onGoToAnnotation: (a: AnnotationDTO) => void;
|
2026-09-14 11:35:05 +07:00
|
|
|
|
/** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */
|
|
|
|
|
|
selectedId: string | null;
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
}) {
|
|
|
|
|
|
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
|
|
|
|
|
|
|
2026-09-14 11:35:05 +07:00
|
|
|
|
// คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks)
|
|
|
|
|
|
// แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป
|
|
|
|
|
|
// (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน)
|
|
|
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
2026-09-15 14:12:51 +07:00
|
|
|
|
// id ล่าสุดที่ประกาศไปแล้ว — บังคับสลับแท็บเฉพาะตอน selectedId "เพิ่งเปลี่ยน" เท่านั้น
|
|
|
|
|
|
// ไม่ใช่ทุกครั้งที่ effect รัน เพราะ dep มี tab ด้วย ไม่งั้นผู้ใช้กดกลับมาแท็บ bookmarks
|
|
|
|
|
|
// ไม่ได้เลยตอนมีรายการถูกเลือกอยู่ (ถูกดึงกลับ annotations ทันที); เคลียร์เป็น null
|
|
|
|
|
|
// ตอนไม่มีรายการถูกเลือก เพื่อให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้เหมือนเดิม
|
|
|
|
|
|
const announcedSelRef = useRef<string | null>(null);
|
2026-09-14 11:35:05 +07:00
|
|
|
|
useEffect(() => {
|
2026-09-15 14:12:51 +07:00
|
|
|
|
if (!selectedId) {
|
|
|
|
|
|
announcedSelRef.current = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const firstAnnounce = announcedSelRef.current !== selectedId;
|
|
|
|
|
|
announcedSelRef.current = selectedId;
|
|
|
|
|
|
if (firstAnnounce && tab !== "annotations") setTab("annotations");
|
2026-09-14 11:35:05 +07:00
|
|
|
|
const raf = requestAnimationFrame(() => {
|
|
|
|
|
|
listRef.current
|
|
|
|
|
|
?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`)
|
|
|
|
|
|
?.scrollIntoView({ block: "nearest" });
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => cancelAnimationFrame(raf);
|
|
|
|
|
|
}, [selectedId, tab]);
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
|
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
|
|
|
|
|
|
const [searching, setSearching] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function runSearch(e: FormEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const q = searchQuery.trim();
|
|
|
|
|
|
if (!q || !fileId) return;
|
|
|
|
|
|
setSearching(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSearchResults(await searchAnnotations(fileId, q));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("ค้นหาไม่สำเร็จ", err);
|
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSearching(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
/** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations
|
|
|
|
|
|
* ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกับ visibleAnnotations ของ
|
|
|
|
|
|
* PDFViewer — เปลี่ยนตัวกรองหลังค้นแล้ว ผลกรองทันทีไม่ต้องค้นใหม่ */
|
|
|
|
|
|
const visibleSearchResults = useMemo(() => {
|
|
|
|
|
|
if (searchResults === null) return null;
|
|
|
|
|
|
if (userFilter === "all") return searchResults;
|
|
|
|
|
|
const target = userFilter === "mine" ? userId : userFilter;
|
|
|
|
|
|
return searchResults.filter((a) => a.userId === target);
|
|
|
|
|
|
}, [searchResults, userFilter, userId]);
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
|
|
|
|
|
|
const [historyFor, setHistoryFor] = useState<AnnotationDTO | null>(null);
|
|
|
|
|
|
const [versions, setVersions] = useState<AnnotationVersionDTO[] | null>(null);
|
|
|
|
|
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
|
|
|
|
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
async function openHistory(a: AnnotationDTO) {
|
|
|
|
|
|
setHistoryFor(a);
|
|
|
|
|
|
setVersions(null);
|
|
|
|
|
|
setHistoryError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
setVersions(await listVersions(a.id));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** กู้คืน = เขียนกลับ row เดิม (ต้นทางถูกลบไปแล้ว = สร้างใหม่ id ใหม่)
|
|
|
|
|
|
* sync ผ่าน SSE update/create event (รวมแท็บนี้) */
|
|
|
|
|
|
async function handleRestore(versionId: string) {
|
|
|
|
|
|
setRestoringId(versionId);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await restoreVersion(versionId, userId);
|
|
|
|
|
|
setHistoryFor(null);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setRestoringId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// Escape ปิด dialog ประวัติ (UX audit: เดิมปิดได้ด้วยปุ่ม "ปิด" เท่านั้น)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!historyFor) return;
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === "Escape") setHistoryFor(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [historyFor]);
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
|
|
|
|
|
|
// กับ "จัดการแล้ว" (comment ที่ถูก resolve)
|
|
|
|
|
|
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
|
|
|
|
|
|
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
|
|
|
|
|
|
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
|
|
|
|
|
|
const bmRootPages = groupByPage(bookmarks.filter((b) => !b.parentId).sort(byPageOrder));
|
|
|
|
|
|
const annPages = groupByPage(annotations);
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
|
|
|
|
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
|
|
// FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail
|
|
|
|
|
|
// 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา
|
|
|
|
|
|
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
|
|
|
|
|
|
const copyTimerRef = useRef<number | null>(null);
|
|
|
|
|
|
function copyAnnotationLink(a: AnnotationDTO) {
|
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
|
|
|
|
const url = `${window.location.origin}${withBasePath(`/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`)}`;
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
if (copyTimerRef.current !== null) {
|
|
|
|
|
|
window.clearTimeout(copyTimerRef.current);
|
|
|
|
|
|
copyTimerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const show = (ok: boolean) => {
|
|
|
|
|
|
setCopiedId(ok ? a.id : null);
|
|
|
|
|
|
setCopyFailedId(ok ? null : a.id);
|
|
|
|
|
|
copyTimerRef.current = window.setTimeout(() => {
|
|
|
|
|
|
copyTimerRef.current = null;
|
|
|
|
|
|
setCopiedId(null);
|
|
|
|
|
|
setCopyFailedId(null);
|
|
|
|
|
|
}, 1500);
|
|
|
|
|
|
};
|
|
|
|
|
|
// บริบทที่ไม่มี clipboard API (HTTP ที่ไม่ใช่ localhost) — optional chaining
|
|
|
|
|
|
// จะตัดทั้ง chain ทิ้ง ต้องตรวจก่อนถึงจะมี feedback fail จริง
|
|
|
|
|
|
if (!navigator.clipboard?.writeText) {
|
|
|
|
|
|
show(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
navigator.clipboard.writeText(url).then(
|
|
|
|
|
|
() => show(true),
|
|
|
|
|
|
() => show(false),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── โน้ตคำอธิบายของรายการ (N1) — แก้ไขเฉพาะรายการของตัวเอง แสดงให้ทุกคน ─────
|
|
|
|
|
|
const [noteFor, setNoteFor] = useState<AnnotationDTO["id"] | null>(null);
|
|
|
|
|
|
const [noteDraft, setNoteDraft] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
function startNoteEdit(a: AnnotationDTO) {
|
|
|
|
|
|
setNoteFor(a.id);
|
|
|
|
|
|
setNoteDraft(annotationNote(a));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveNote(a: AnnotationDTO) {
|
|
|
|
|
|
const msg = noteDraft.trim();
|
|
|
|
|
|
setNoteFor(null);
|
|
|
|
|
|
if (a.type === "COMMENT" || msg === annotationNote(a)) return; // ไม่มีอะไรเปลี่ยน
|
|
|
|
|
|
onUpdateAnnotation(a.id, withNote(a.data as Exclude<AnnotationData, CommentData>, msg));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// ─── เมนู ⋯ ของแถวรายการ (annotation/bookmark) — UX audit 2026-09-16 ───────────
|
|
|
|
|
|
const [menuFor, setMenuFor] = useState<RowMenuTarget | null>(null);
|
|
|
|
|
|
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
|
|
|
|
|
// สถานะ "แถวยืนยันลบ" ในเมนู — การลบเป็น action ที่กลับไม่ได้บนจอสัมผัส (ไม่มี Ctrl+Z)
|
|
|
|
|
|
const [menuConfirmDelete, setMenuConfirmDelete] = useState(false);
|
|
|
|
|
|
// ปุ่ม ⋯ ที่เปิดเมนูอยู่ — outside-mousedown เว้นปุ่มนี้ให้ onClick toggle เอง
|
|
|
|
|
|
const menuAnchorRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const closeRowMenu = useCallback(() => {
|
|
|
|
|
|
setMenuFor(null);
|
|
|
|
|
|
setMenuPos(null);
|
|
|
|
|
|
setMenuConfirmDelete(false);
|
|
|
|
|
|
menuAnchorRef.current = null;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
function openRowMenu(btn: HTMLButtonElement, target: RowMenuTarget) {
|
|
|
|
|
|
menuAnchorRef.current = btn;
|
|
|
|
|
|
setMenuPos(rowMenuPos(btn.getBoundingClientRect()));
|
|
|
|
|
|
setMenuFor(target);
|
|
|
|
|
|
setMenuConfirmDelete(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ปิดเมนู 3 ทาง: คลิกนอก (เว้นโหนดใน portal + ปุ่ม anchor กัน toggle bug) /
|
|
|
|
|
|
// Escape / scroll ใด ๆ (scroll ของ element ไม่ bubble ถึง document — ต้องใช้
|
|
|
|
|
|
// capture ถึงจับ scroll ของ listRef ของแผงนี้เองได้)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!menuFor) return;
|
|
|
|
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
|
|
|
|
const t = e.target;
|
|
|
|
|
|
if (!(t instanceof Element)) return;
|
|
|
|
|
|
if (t.closest("[data-tool-menu]")) return; // คลิกในเมนู — ให้ handler ของรายการทำงาน
|
|
|
|
|
|
if (menuAnchorRef.current?.contains(t)) return; // anchor — click ถัดไป toggle เอง
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
};
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === "Escape") closeRowMenu();
|
|
|
|
|
|
};
|
|
|
|
|
|
const onScroll = (e: Event) => {
|
|
|
|
|
|
// เว้น scroll ภายในเมนูเอง (เมนูมี overflowY ตอนจอเตี้ย)
|
|
|
|
|
|
if (e.target instanceof Element && e.target.closest("[data-tool-menu]")) return;
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("mousedown", onMouseDown);
|
|
|
|
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
|
|
document.addEventListener("scroll", onScroll, true);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener("mousedown", onMouseDown);
|
|
|
|
|
|
document.removeEventListener("keydown", onKey);
|
|
|
|
|
|
document.removeEventListener("scroll", onScroll, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [menuFor, closeRowMenu]);
|
|
|
|
|
|
|
|
|
|
|
|
// realtime (SSE): แถวที่เปิดเมนูถูกลบไป = id หาไม่เจอ → ปิดเมนูเอง
|
|
|
|
|
|
// (นับ snapshot ผลค้นหา F14 ด้วย — แถวนั้นไม่ได้อยู่ใน prop annotations ที่กรอง F4 แล้ว)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!menuFor) return;
|
|
|
|
|
|
const alive =
|
|
|
|
|
|
menuFor.kind === "annotation"
|
|
|
|
|
|
? annotations.some((a) => a.id === menuFor.id) ||
|
|
|
|
|
|
(visibleSearchResults ?? []).some((a) => a.id === menuFor.id)
|
|
|
|
|
|
: bookmarks.some((b) => b.id === menuFor.id);
|
|
|
|
|
|
if (!alive) closeRowMenu();
|
|
|
|
|
|
}, [menuFor, annotations, bookmarks, visibleSearchResults, closeRowMenu]);
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ──
|
|
|
|
|
|
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// มุมมองรองในแต่ละแท็บ: ที่คั่นหน้า (topic=ต้นไม้หัวข้อ ↔ page) / หมายเหตุ (status ↔ page)
|
|
|
|
|
|
const [bmView, setBmView] = useState<"topic" | "page">("topic");
|
|
|
|
|
|
const [annView, setAnnView] = useState<"status" | "page">("status");
|
|
|
|
|
|
// หน้าที่กางไว้ขอมุมมอง "ตามหน้า" (ใช้ร่วม 2 แท็บ — เก็บ pageIndex; ว่าง = หุบทั้งหมด)
|
|
|
|
|
|
const [expandedPages, setExpandedPages] = useState<Set<number>>(new Set());
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const));
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// เป้าหมายเมนู ⋯ ที่ lookup จาก state สดทุก render (SSE อัปเดตตลอด)
|
|
|
|
|
|
// — null = แถวหายไปแล้ว (ผลค้นหา F14 เป็น snapshot จึง lookup เสริมจาก searchResults)
|
|
|
|
|
|
const menuAnn =
|
|
|
|
|
|
menuFor?.kind === "annotation"
|
|
|
|
|
|
? (annotations.find((a) => a.id === menuFor.id) ??
|
|
|
|
|
|
searchResults?.find((a) => a.id === menuFor.id)) ??
|
|
|
|
|
|
null
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const menuBm =
|
|
|
|
|
|
menuFor?.kind === "bookmark" ? (bookmarks.find((b) => b.id === menuFor.id) ?? null) : null;
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
const [editBookmark, setEditBookmark] = useState<{
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
note: string;
|
|
|
|
|
|
parentId: string | null;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
function toggleCollapsed(id: string) {
|
|
|
|
|
|
setCollapsedIds((prev) => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
|
|
else next.add(id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
function togglePageExpanded(pageIndex: number) {
|
|
|
|
|
|
setExpandedPages((prev) => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
if (next.has(pageIndex)) next.delete(pageIndex);
|
|
|
|
|
|
else next.add(pageIndex);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
function startBookmarkEdit(b: BookmarkDTO) {
|
|
|
|
|
|
setEditBookmark({
|
|
|
|
|
|
id: b.id,
|
|
|
|
|
|
title: b.title,
|
|
|
|
|
|
note: b.note ?? "",
|
|
|
|
|
|
parentId: b.parentId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveBookmarkEdit(b: BookmarkDTO) {
|
|
|
|
|
|
if (!editBookmark || editBookmark.id !== b.id) return;
|
|
|
|
|
|
const patch: { title?: string; note?: string | null; parentId?: string | null } = {};
|
|
|
|
|
|
const title = editBookmark.title.trim();
|
|
|
|
|
|
if (title && title !== b.title) patch.title = title;
|
|
|
|
|
|
const note = editBookmark.note.trim();
|
|
|
|
|
|
if (note !== (b.note ?? "")) patch.note = note || null;
|
|
|
|
|
|
if (editBookmark.parentId !== (b.parentId ?? null)) {
|
|
|
|
|
|
patch.parentId = editBookmark.parentId;
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditBookmark(null);
|
|
|
|
|
|
if (Object.keys(patch).length > 0) onUpdateBookmark(b.id, patch);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// ─── N6: ค้นหา bookmark พร้อมเงื่อนไข — คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ ──
|
|
|
|
|
|
const [bmQuery, setBmQuery] = useState("");
|
|
|
|
|
|
const [bmConditions, setBmConditions] = useState<BmConditions>(EMPTY_BM_CONDITIONS);
|
|
|
|
|
|
const [bmShowConditions, setBmShowConditions] = useState(false);
|
|
|
|
|
|
/** null = ยังไม่ค้น (แสดงต้นไม้ปกติ) — มีค่า = แสดงผลลัพธ์แทนต้นไม้จนกว่าจะกดปิด */
|
|
|
|
|
|
const [bmResults, setBmResults] = useState<BookmarkDTO[] | null>(null);
|
|
|
|
|
|
const [bmSearching, setBmSearching] = useState(false);
|
|
|
|
|
|
/** error ของฟอร์มค้นหา (input ผิด/format ผิด/action fail) — แสดงแทนการเห็น "ไม่พบ"
|
|
|
|
|
|
* ที่หลอกลวง เช่น พิมพ์ 0/1.5 ในช่องหน้า แล้วเงื่อนไขหายเงียบจน server throw */
|
|
|
|
|
|
const [bmSearchError, setBmSearchError] = useState<string | null>(null);
|
|
|
|
|
|
/** ลายเซ็น (คำค้น+เงื่อนไข) ของการค้นล่าสุดที่สำเร็จ — ต่างจากค่าในฟอร์มปัจจุบัน =
|
|
|
|
|
|
* ผลที่กำลังแสดงเป็นของเงื่อนไขเก่า แสดง hint เตือนให้กด "หา" ใหม่ (ตั้งค่าเฉพาะ
|
|
|
|
|
|
* path สำเร็จหลัง await — ถ้าตั้งก่อนแล้ว request พัง hint จะเตือนทั้งที่ผลไม่เคยอัปเดต) */
|
|
|
|
|
|
const [bmSearchedSig, setBmSearchedSig] = useState<string | null>(null);
|
|
|
|
|
|
/** สรุปเงื่อนไข (รวมคำค้น) ของการค้นล่าสุดที่สำเร็จ — แสดงใต้หัวผลให้ผลอ่านรู้เรื่อง
|
|
|
|
|
|
* โดยไม่ต้องจำว่าค้นด้วยอะไร (snapshot คู่กับ bmSearchedSig — เฉพาะ path สำเร็จ) */
|
|
|
|
|
|
const [bmSearchedSummary, setBmSearchedSummary] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
/** เงื่อนไขที่ตั้งอยู่ทั้งหมด (คำค้นนับ 1) — ใช้ที่ badge ของปุ่มเงื่อนไข + disable ปุ่มหา */
|
|
|
|
|
|
const activeConditionCount =
|
|
|
|
|
|
(bmQuery.trim() !== "" ? 1 : 0) +
|
|
|
|
|
|
Object.values(bmConditions).filter((v) => v !== "").length;
|
|
|
|
|
|
|
|
|
|
|
|
/** รายชื่อผู้บันทึก bookmark — unique จาก bookmarks prop
|
|
|
|
|
|
* (ห้ามปนกับ dropdown กรอง F4 ที่ derive จาก annotations เท่านั้น) */
|
|
|
|
|
|
const bookmarkAuthors = useMemo(() => {
|
|
|
|
|
|
const seen = new Map<string, string>();
|
|
|
|
|
|
for (const b of bookmarks) {
|
|
|
|
|
|
if (!seen.has(b.userId)) seen.set(b.userId, b.userName || "ไม่ทราบชื่อ");
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(seen, ([userId, userName]) => ({ userId, userName })).sort((x, y) =>
|
|
|
|
|
|
x.userName.localeCompare(y.userName, "th"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [bookmarks]);
|
|
|
|
|
|
|
|
|
|
|
|
// ─── แถวสรุปเงื่อนไข (UX design 2026-09-16) — เงื่อนไข active ต้องมองเห็นเป็น
|
|
|
|
|
|
// ข้อความตลอดแม้แผงหุบ/ปิดผลแล้ว (เดิม badge ตัวเลขเห็นแค่จำนวน เงื่อนไขเก่าจึง
|
|
|
|
|
|
// กรองการค้นถัดไปเงียบ ๆ ได้) — สรุปสดจากฟอร์ม ไม่รวมคำค้น (อยู่ใน input ข้างบน)
|
|
|
|
|
|
const hasBmConditionValues = Object.values(bmConditions).some((v) => v !== "");
|
|
|
|
|
|
const bmConditionUserName = bmConditions.userId
|
|
|
|
|
|
? bookmarkAuthors.find((u) => u.userId === bmConditions.userId)?.userName
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const bmLiveSummary = bmConditionSummary(bmConditions, undefined, bmConditionUserName);
|
|
|
|
|
|
|
|
|
|
|
|
async function runBookmarkSearch(e: FormEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!fileId || activeConditionCount === 0 || bmSearching) return;
|
|
|
|
|
|
// input type=number ล็อกแค่ spinner — พิมพ์มือ 0/1.5 ได้: ตรวจก่อน submit
|
|
|
|
|
|
// ไม่งั้นเงื่อนไขหายเงียบ (toPageCondition → undefined) จนเห็น "ไม่พบ" ผิด ๆ
|
|
|
|
|
|
const pageFrom = toPageCondition(bmConditions.pageFrom);
|
|
|
|
|
|
const pageTo = toPageCondition(bmConditions.pageTo);
|
|
|
|
|
|
if (
|
|
|
|
|
|
(bmConditions.pageFrom !== "" && pageFrom === undefined) ||
|
|
|
|
|
|
(bmConditions.pageTo !== "" && pageTo === undefined)
|
|
|
|
|
|
) {
|
|
|
|
|
|
setBmSearchError("ช่วงหน้าต้องเป็นจำนวนเต็ม ≥ 1");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setBmSearchError(null);
|
|
|
|
|
|
setBmSearching(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const q = bmQuery.trim() || undefined;
|
|
|
|
|
|
setBmResults(
|
|
|
|
|
|
await searchBookmarks({
|
|
|
|
|
|
fileId,
|
|
|
|
|
|
q,
|
|
|
|
|
|
pageFrom,
|
|
|
|
|
|
pageTo,
|
|
|
|
|
|
userId: bmConditions.userId || undefined,
|
|
|
|
|
|
dateFrom: bmConditions.dateFrom || undefined,
|
|
|
|
|
|
dateTo: bmConditions.dateTo || undefined,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
// ลายเซ็นของค่าที่ค้นจริง (สร้างจากตัวแปรเดียวกับที่ส่งให้ server เพื่อบวกเทียบ
|
|
|
|
|
|
// กับ JSON.stringify([bmQuery.trim(), bmConditions]) ตอน render ได้ถูกต้อง)
|
|
|
|
|
|
setBmSearchedSig(JSON.stringify([q ?? "", bmConditions]));
|
|
|
|
|
|
// snapshot สรุป "ค้นด้วยอะไร" ของผลที่กำลังจะแสดง — หาผู้บันทึกจากรายชื่อปัจจุบัน
|
|
|
|
|
|
setBmSearchedSummary(
|
|
|
|
|
|
bmConditionSummary(
|
|
|
|
|
|
bmConditions,
|
|
|
|
|
|
q,
|
|
|
|
|
|
bmConditions.userId
|
|
|
|
|
|
? bookmarkAuthors.find((u) => u.userId === bmConditions.userId)?.userName
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// แสดง error จริง ไม่กลืนเป็น "ไม่พบ" — "ไม่พบ" สงวนไว้เฉพาะผลลัพธ์ว่างจริง
|
|
|
|
|
|
console.error("ค้นหา bookmark ไม่สำเร็จ", err);
|
|
|
|
|
|
setBmSearchError(err instanceof Error ? err.message : "ค้นหาไม่สำเร็จ ลองอีกครั้ง");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBmSearching(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
|
2026-09-16 13:24:33 +07:00
|
|
|
|
const children = bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder);
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
const hasChildren = children.length > 0;
|
|
|
|
|
|
const collapsed = collapsedIds.has(b.id);
|
|
|
|
|
|
const editing = editBookmark?.id === b.id;
|
|
|
|
|
|
// FIX-9: guard null ครั้งเดียวต้น branch แทน editBookmark! ซ้ำ 6 จุด
|
|
|
|
|
|
const eb = editing ? editBookmark : null;
|
|
|
|
|
|
// เป้าหมายที่ย้ายเข้าได้ = ไม่ใช่ตัวเอง/ลูกหลาน และความลึกแม่ + ความสูง subtree ตัวเอง ≤ 3 ระดับ
|
|
|
|
|
|
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
|
|
|
|
|
|
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
|
|
|
|
|
|
const movableTo = editing
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
? bookmarks
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(t) =>
|
|
|
|
|
|
t.id !== b.id &&
|
|
|
|
|
|
!isDescendantOf(t.id, b.id, bookmarkById) &&
|
|
|
|
|
|
bookmarkDepth(t, bookmarkById) +
|
|
|
|
|
|
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
|
|
|
|
|
|
3,
|
|
|
|
|
|
)
|
|
|
|
|
|
.sort(byPageOrder) // M7: เรียงตามลำดับหน้าเหมือนต้นไม้ — หาหัวข้อแม่ใน select ง่ายขึ้น
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
: [];
|
2026-09-11 18:05:38 +07:00
|
|
|
|
// พื้นหลัง hover อยู่ที่ div แถวรายการ ไม่ใช่ li — li ครอบ <ul> ลูกทั้งก้อน
|
|
|
|
|
|
// ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว)
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// UX audit: indent + เส้น guide ย้ายไปอยู่ที่ <ul> ลูก แถวจึงใช้ pad เดียวกันทุกระดับ
|
|
|
|
|
|
const pad = "px-1";
|
|
|
|
|
|
// แสดงปุ่ม ⋯ เมื่อมี action อย่างน้อยหนึ่ง: เพิ่ม sub (depth < 3) หรือเจ้าของแก้/ลบ
|
|
|
|
|
|
const hasRowMenu = (depth < 3 && userId !== "") || b.userId === userId;
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
return (
|
2026-09-11 18:05:38 +07:00
|
|
|
|
<li key={b.id}>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{eb ? (
|
2026-09-11 18:05:38 +07:00
|
|
|
|
<div className={`rounded py-1 ${pad}`}>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<input
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
value={eb.title}
|
|
|
|
|
|
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (e.key === "Enter") saveBookmarkEdit(b);
|
|
|
|
|
|
if (e.key === "Escape") setEditBookmark(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="ชื่อหัวข้อ"
|
|
|
|
|
|
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={eb.note}
|
|
|
|
|
|
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (e.key === "Escape") setEditBookmark(null);
|
|
|
|
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="โน้ต (ไม่บังคับ)"
|
|
|
|
|
|
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="mt-1 text-[11px] text-slate-500">วางที่</p>
|
2026-09-10 18:02:01 +07:00
|
|
|
|
<Select
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
value={eb.parentId ?? ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setEditBookmark({ ...eb, parentId: e.target.value || null })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
|
|
|
|
|
{movableTo.map((h) => (
|
|
|
|
|
|
<option key={h.id} value={h.id}>
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
ใต้ "{h.title}" (หน้า {h.pageIndex + 1})
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
2026-09-10 18:02:01 +07:00
|
|
|
|
</Select>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => setEditBookmark(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
ยกเลิก
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => saveBookmarkEdit(b)}
|
|
|
|
|
|
>
|
|
|
|
|
|
บันทึก
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-09-11 18:05:38 +07:00
|
|
|
|
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{hasChildren ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
|
|
|
|
|
|
className={`shrink-0 text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => toggleCollapsed(b.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{collapsed ? (
|
|
|
|
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="w-3.5 shrink-0" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
aria-level={depth}
|
|
|
|
|
|
className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => onGoToBookmark(b)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Bookmark
|
|
|
|
|
|
className={`h-3.5 w-3.5 shrink-0 ${
|
|
|
|
|
|
depth === 1 ? "text-amber-500" : "text-amber-300"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
|
<span
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`block truncate text-[13px] leading-snug ${
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
depth === 1 ? "font-semibold text-slate-700" : ""
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{b.title}
|
|
|
|
|
|
</span>
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
|
|
|
|
|
|
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
|
|
|
|
|
|
<span className="block text-xs text-slate-500">
|
|
|
|
|
|
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
|
|
|
|
|
|
</span>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{b.note && (
|
|
|
|
|
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
|
|
|
|
|
{b.note}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
|
|
|
|
|
|
{hasChildren && (
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{children.length}
|
|
|
|
|
|
</span>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
)}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* action เดียวของแถว — เมนู ⋯ (เพิ่ม sub/แก้ไข/ลบ ตามสิทธิ์) ผ่าน portal */}
|
|
|
|
|
|
{hasRowMenu && (
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
aria-label="จัดการหัวข้อนี้"
|
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
|
aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id}
|
|
|
|
|
|
className={`shrink-0 text-slate-500 hover:text-slate-600 group-hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu();
|
|
|
|
|
|
else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id });
|
|
|
|
|
|
}}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasChildren && !collapsed && (
|
2026-09-16 13:24:33 +07:00
|
|
|
|
// เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ
|
|
|
|
|
|
// ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ)
|
|
|
|
|
|
<ul className="ml-3 space-y-0.5 border-l border-slate-200 pl-1">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{children.map((c) => renderBookmarkNode(c, depth + 1))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderAnnotationRow = (a: AnnotationDTO) => {
|
|
|
|
|
|
const note = annotationNote(a);
|
|
|
|
|
|
const editingNote = noteFor === a.id;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={a.id}
|
2026-09-14 11:35:05 +07:00
|
|
|
|
data-ann-id={a.id}
|
|
|
|
|
|
className={`group rounded px-2 py-1.5 ${
|
|
|
|
|
|
a.id === selectedId
|
|
|
|
|
|
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
|
|
|
|
|
|
: "hover:bg-slate-100"
|
|
|
|
|
|
} ${a.resolved ? "opacity-60" : ""}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`flex min-w-0 flex-1 items-start gap-2 text-left ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// ระหว่างแก้โน้ต: คลิกข้อความไม่กระโดดหน้า (กัน scroll หนีตอน editor เปิดอยู่)
|
|
|
|
|
|
if (editingNote) return;
|
|
|
|
|
|
onGoToAnnotation(a);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<TypeIcon type={a.type} data={a.data} />
|
|
|
|
|
|
<span className="min-w-0">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
|
|
|
|
|
|
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
|
|
|
|
|
|
<span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span>
|
|
|
|
|
|
<span className="block text-xs text-slate-500">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
หน้า {a.pageIndex + 1} · {a.userName}
|
|
|
|
|
|
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
|
|
|
|
|
|
? ` · ตอบ ${replyCounts[a.id]}`
|
|
|
|
|
|
: ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* ปุ่ม action เดียวของแถว — เปิดเมนู (คัดลอกลิงก์ F12/โน้ต N1/ประวัติ F19/ลบ ตามสิทธิ์)
|
|
|
|
|
|
แทนไอคอนติดกัน 4 ตัวที่จิ้มพลาดง่ายบนจอสัมผัส; ตัวปุ่มยังแสดง feedback
|
|
|
|
|
|
คัดลอกลิงก์ (Check เขียว/X แดง 1.5s) หลังเลือกจากเมนูแล้วเมนูปิดตัว */}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
aria-label="จัดการหมายเหตุนี้"
|
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
|
aria-expanded={menuFor?.kind === "annotation" && menuFor.id === a.id}
|
|
|
|
|
|
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"}
|
|
|
|
|
|
className={`shrink-0 ${FOCUS_RING_CLS} ${
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
copiedId === a.id
|
|
|
|
|
|
? "text-emerald-500"
|
|
|
|
|
|
: copyFailedId === a.id
|
|
|
|
|
|
? "text-red-500"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
: "text-slate-500 hover:text-slate-600 group-hover:text-slate-600"
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
}`}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu();
|
|
|
|
|
|
else openRowMenu(e.currentTarget, { kind: "annotation", id: a.id });
|
|
|
|
|
|
}}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
>
|
|
|
|
|
|
{copiedId === a.id ? (
|
|
|
|
|
|
<Check className="h-4 w-4" />
|
|
|
|
|
|
) : copyFailedId === a.id ? (
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
) : (
|
2026-09-16 13:24:33 +07:00
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{editingNote ? (
|
|
|
|
|
|
<div className="mt-1 pl-6">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={noteDraft}
|
|
|
|
|
|
onChange={(e) => setNoteDraft(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของ viewer
|
|
|
|
|
|
if (e.key === "Escape") setNoteFor(null);
|
|
|
|
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
|
|
|
|
|
|
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => setNoteFor(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
ยกเลิก
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => saveNote(a)}
|
|
|
|
|
|
>
|
|
|
|
|
|
บันทึก
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
note && (
|
|
|
|
|
|
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
|
|
|
|
|
|
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
|
|
|
|
|
|
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
|
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
|
|
|
|
|
|
<div className="shrink-0 border-b border-slate-200 p-2">
|
2026-09-10 18:02:01 +07:00
|
|
|
|
<Select
|
2026-09-16 13:24:33 +07:00
|
|
|
|
aria-label="เลือกเจ้าของหมายเหตุที่แสดง"
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
|
|
|
|
|
|
value={userFilter}
|
|
|
|
|
|
onChange={(e) => onUserFilterChange(e.target.value)}
|
|
|
|
|
|
>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
<option value="all">เจ้าของหมายเหตุ: ทุกคน</option>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<option value="mine">เฉพาะของฉัน</option>
|
|
|
|
|
|
{users.map((u) => (
|
|
|
|
|
|
<option key={u.userId} value={u.userId}>
|
|
|
|
|
|
เฉพาะ {u.userName}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
2026-09-10 18:02:01 +07:00
|
|
|
|
</Select>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */}
|
|
|
|
|
|
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{(
|
|
|
|
|
|
[
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
["bookmarks", `ที่คั่นหน้า (${bookmarks.length})`],
|
|
|
|
|
|
["annotations", `หมายเหตุ (${annotations.length})`],
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
] as const
|
|
|
|
|
|
).map(([key, label]) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={tab === key}
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
tab === key
|
|
|
|
|
|
? "border-b-2 border-primary text-slate-900"
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
: "text-slate-500 hover:text-slate-600"
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => setTab(key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า
|
|
|
|
|
|
(แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */}
|
|
|
|
|
|
{tab === "bookmarks" && (
|
|
|
|
|
|
<div className="shrink-0 space-y-1.5 border-b border-slate-200 p-2">
|
|
|
|
|
|
{/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว
|
|
|
|
|
|
(เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ
|
|
|
|
|
|
~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */}
|
|
|
|
|
|
<form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}>
|
|
|
|
|
|
<div className="relative min-w-0 flex-1">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={bmQuery}
|
|
|
|
|
|
onChange={(e) => setBmQuery(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
|
|
|
|
|
|
placeholder="ชื่อ หรือ โน้ต…"
|
|
|
|
|
|
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={activeConditionCount === 0 || bmSearching}
|
|
|
|
|
|
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{bmSearching ? "…" : "หา"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{/* แถวสรุปเงื่อนไข — toggle แผงแบบเต็มความกว้าง (เป้าใหญ่จิ้มง่ายบนจอสัมผัส)
|
|
|
|
|
|
แสดงเงื่อนไขที่ตั้งอยู่ "เป็นข้อความ" สดตามฟอร์ม ตลอดแม้แผงหุบ/ปิดผลแล้ว —
|
|
|
|
|
|
แทน badge ตัวเลขที่เห็นแค่จำนวน / ไม่มีเงื่อนไข = บอกขอบเขตที่ตั้งได้เลย */}
|
2026-09-17 10:09:57 +07:00
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-expanded={bmShowConditions}
|
|
|
|
|
|
onClick={() => setBmShowConditions((v) => !v)}
|
|
|
|
|
|
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-slate-100 ${FOCUS_RING_CLS} ${
|
|
|
|
|
|
hasBmConditionValues ? "text-primary" : "text-slate-500"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SlidersHorizontal className="h-3 w-3 shrink-0" />
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-left">
|
|
|
|
|
|
{hasBmConditionValues ? bmLiveSummary : "หน้า · ผู้บันทึก · วันที่"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{bmShowConditions ? (
|
|
|
|
|
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-09-17 13:43:00 +07:00
|
|
|
|
{/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16)
|
2026-09-17 10:09:57 +07:00
|
|
|
|
ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */}
|
|
|
|
|
|
<a
|
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
|
|
|
|
href={withBasePath(`/api/bookmarks/export?fileId=${fileId}`)}
|
2026-09-17 13:43:00 +07:00
|
|
|
|
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
|
|
|
|
|
|
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
|
2026-09-17 10:09:57 +07:00
|
|
|
|
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
>
|
2026-09-17 13:43:00 +07:00
|
|
|
|
<Braces className="h-3.5 w-3.5" />
|
2026-09-17 10:09:57 +07:00
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{bmShowConditions && (
|
|
|
|
|
|
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
|
|
|
|
|
|
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
|
|
|
|
|
|
// ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน)
|
|
|
|
|
|
<div className="space-y-1.5 text-[11px] text-slate-600">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="block pb-0.5">หน้า</span>
|
|
|
|
|
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
aria-label="หน้าเริ่ม"
|
|
|
|
|
|
value={bmConditions.pageFrom}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setBmConditions({ ...bmConditions, pageFrom: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
placeholder="จาก"
|
|
|
|
|
|
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-slate-400">–</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
aria-label="หน้าสุดท้าย"
|
|
|
|
|
|
value={bmConditions.pageTo}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setBmConditions({ ...bmConditions, pageTo: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
placeholder="ถึง"
|
|
|
|
|
|
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="block pb-0.5">ผู้บันทึก</span>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="กรองตามผู้บันทึก bookmark"
|
|
|
|
|
|
value={bmConditions.userId}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setBmConditions({ ...bmConditions, userId: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
className="w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">ทุกคน</option>
|
|
|
|
|
|
{bookmarkAuthors.map((u) => (
|
|
|
|
|
|
<option key={u.userId} value={u.userId}>
|
|
|
|
|
|
{u.userName}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="block pb-0.5">บันทึกวันที่</span>
|
|
|
|
|
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
aria-label="วันที่เริ่ม"
|
|
|
|
|
|
value={bmConditions.dateFrom}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setBmConditions({ ...bmConditions, dateFrom: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-slate-400">–</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
aria-label="วันที่สุดท้าย"
|
|
|
|
|
|
value={bmConditions.dateTo}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setBmConditions({ ...bmConditions, dateTo: e.target.value })
|
|
|
|
|
|
}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`rounded px-1.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน
|
|
|
|
|
|
setBmConditions(EMPTY_BM_CONDITIONS);
|
|
|
|
|
|
setBmQuery("");
|
|
|
|
|
|
setBmResults(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
ล้างทั้งหมด
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า)
|
|
|
|
|
|
— มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */}
|
|
|
|
|
|
{bmSearchError ? (
|
|
|
|
|
|
<p className="px-1 text-[11px] text-red-600">{bmSearchError}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
bmResults !== null &&
|
|
|
|
|
|
bmSearchedSig !== null &&
|
|
|
|
|
|
bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && (
|
|
|
|
|
|
<p className="px-1 text-[11px] text-amber-600">
|
|
|
|
|
|
เงื่อนไขเปลี่ยนแล้ว กด "หา" เพื่อค้นใหม่
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — อยู่ใต้แท็บเฉพาะแท็บหมายเหตุ (ค้นหาอะไร
|
|
|
|
|
|
อยู่ในแท็บนั้น สมมาตรกับช่องค้นหาของแท็บที่คั่นหน้า) ผลลัพธ์ render แทน
|
|
|
|
|
|
เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */}
|
|
|
|
|
|
{tab === "annotations" && (
|
|
|
|
|
|
<form
|
|
|
|
|
|
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
|
|
|
|
|
|
onSubmit={(e) => void runSearch(e)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="relative min-w-0 flex-1">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
|
|
|
|
|
|
placeholder="ค้นหาในหมายเหตุ…"
|
|
|
|
|
|
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={!searchQuery.trim() || searching}
|
|
|
|
|
|
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{searching ? "…" : "หา"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-14 11:35:05 +07:00
|
|
|
|
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{tab === "bookmarks" ? (
|
2026-09-16 13:24:33 +07:00
|
|
|
|
bmResults !== null ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between px-1 pb-1">
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
ผลการค้นหา ({bmResults.length})
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="ปิดผลการค้นหา"
|
|
|
|
|
|
title="กลับไปรายการที่คั่นหน้า"
|
|
|
|
|
|
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={() => setBmResults(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* snapshot "ค้นด้วยอะไร" ของผลที่กำลังแสดง — ผลอ่านรู้เรื่องแม้ผู้ใช้ลืม/
|
|
|
|
|
|
แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */}
|
|
|
|
|
|
{bmSearchedSummary && (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="truncate px-1 pb-1 text-[11px] text-slate-500"
|
|
|
|
|
|
title={bmSearchedSummary}
|
|
|
|
|
|
>
|
|
|
|
|
|
ค้นด้วย: {bmSearchedSummary}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{bmResults.length === 0 ? (
|
|
|
|
|
|
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
|
{bmResults.map((b) => {
|
|
|
|
|
|
const parent = b.parentId ? bookmarkById.get(b.parentId) : undefined;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={b.id}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={() => onGoToBookmark(b)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
|
<span className="block truncate text-[13px] leading-snug">{b.title}</span>
|
|
|
|
|
|
<span className="block text-xs text-slate-500">
|
|
|
|
|
|
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{parent && (
|
|
|
|
|
|
<span className="block truncate text-[11px] text-slate-500">
|
|
|
|
|
|
ใน: {parent.title}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{b.note && (
|
|
|
|
|
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
|
|
|
|
|
{b.note}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : bookmarks.length === 0 ? (
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="p-4 text-center text-xs text-slate-500">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
2026-09-16 13:24:33 +07:00
|
|
|
|
<>
|
|
|
|
|
|
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
|
|
|
|
|
|
<div className="px-1 pb-1.5">
|
|
|
|
|
|
<ViewSwitch
|
|
|
|
|
|
value={bmView}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ key: "topic", label: "ตามหัวข้อ" },
|
|
|
|
|
|
{ key: "page", label: "ตามหน้า" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={setBmView}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{bmView === "page" ? (
|
|
|
|
|
|
bmRootPages.map(([pi, roots]) => {
|
|
|
|
|
|
const collapsed = !expandedPages.has(pi);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section key={pi}>
|
|
|
|
|
|
<PageGroupHeader
|
|
|
|
|
|
pageIndex={pi}
|
|
|
|
|
|
count={roots.length}
|
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
|
onToggle={() => togglePageExpanded(pi)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
|
{roots.map((b) => {
|
|
|
|
|
|
const childCount = bookmarks.filter(
|
|
|
|
|
|
(x) => x.parentId === b.id,
|
|
|
|
|
|
).length;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={b.id}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-slate-100"
|
|
|
|
|
|
onClick={() => onGoToBookmark(b)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
|
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
|
|
|
|
|
|
{b.title}
|
|
|
|
|
|
</span>
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
|
|
|
|
|
|
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
|
|
|
|
|
|
<span className="block text-xs text-slate-500">
|
|
|
|
|
|
{b.userName || "ไม่ทราบชื่อ"}
|
|
|
|
|
|
</span>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{b.note && (
|
|
|
|
|
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
|
|
|
|
|
{b.note}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{childCount > 0 && (
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
<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">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{childCount}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
|
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
|
|
|
|
|
|
{bookmarks
|
|
|
|
|
|
.filter((b) => !b.parentId)
|
|
|
|
|
|
.sort(byPageOrder)
|
|
|
|
|
|
.map((b) => renderBookmarkNode(b, 1))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
)
|
2026-09-16 13:24:33 +07:00
|
|
|
|
) : visibleSearchResults !== null ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between px-1 pb-1">
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
ผลการค้นหา ({visibleSearchResults.length})
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="ปิดผลการค้นหา"
|
|
|
|
|
|
title="กลับไปรายการหมายเหตุ"
|
|
|
|
|
|
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
|
|
|
|
|
onClick={() => setSearchResults(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{visibleSearchResults.length === 0 ? (
|
|
|
|
|
|
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ
|
|
|
|
|
|
<ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
) : annotations.length === 0 ? (
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="p-4 text-center text-xs text-slate-500">
|
2026-09-16 13:24:33 +07:00
|
|
|
|
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คลิกวางคอมเมนต์บนหน้าเอกสาร
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<span className="mt-1 block text-[11px]">
|
|
|
|
|
|
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
|
|
|
|
|
|
</span>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* สลับมุมมองแท็บนี้: ค้าง–จัดแล้ว ↔ แยกตามหน้า */}
|
|
|
|
|
|
<div className="px-1 pb-1.5">
|
|
|
|
|
|
<ViewSwitch
|
|
|
|
|
|
value={annView}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ key: "status", label: "ตามสถานะ" },
|
|
|
|
|
|
{ key: "page", label: "ตามหน้า" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={setAnnView}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{annView === "page" ? (
|
|
|
|
|
|
annPages.map(([pi, items]) => {
|
|
|
|
|
|
const collapsed = !expandedPages.has(pi);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section key={pi}>
|
|
|
|
|
|
<PageGroupHeader
|
|
|
|
|
|
pageIndex={pi}
|
|
|
|
|
|
count={items.length}
|
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
|
onToggle={() => togglePageExpanded(pi)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!collapsed && (
|
2026-09-17 10:09:57 +07:00
|
|
|
|
<ul className="ml-4 mr-1 space-y-1">{items.map(renderAnnotationRow)}</ul>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{pendingItems.length > 0 && (
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-slate-500">
|
|
|
|
|
|
ค้างดำเนินการ
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{resolvedItems.length > 0 && (
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-emerald-500">
|
|
|
|
|
|
จัดการแล้ว
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* F19/A7: dialog render ผ่าน portal ไป document.body —
|
|
|
|
|
|
aside.sticky สร้าง stacking context ครอบ z-50 ของ dialog ไว้ ทำให้ textLayer
|
|
|
|
|
|
(z-[2] ระดับ root) ทับปุ่มจนคลิกไม่ได้ (เจอตอน verify ด้วยเบราว์เซอร์จริง) */}
|
|
|
|
|
|
{typeof document === "undefined"
|
|
|
|
|
|
? null // SSR ไม่มี document — client component ก็ถูก render ฝั่ง server สำหรับ HTML แรก
|
|
|
|
|
|
: createPortal(
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
|
|
|
|
|
|
{historyFor && (
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
<div
|
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
|
|
|
|
|
|
onClick={() => setHistoryFor(null)}
|
|
|
|
|
|
>
|
2026-09-16 13:24:33 +07:00
|
|
|
|
<div
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label="ประวัติการเปลี่ยนแปลง"
|
|
|
|
|
|
className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="mt-0.5 truncate text-[11px] text-slate-500">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{annotationPreviewText(historyFor)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{historyError && (
|
|
|
|
|
|
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
|
|
|
|
|
|
{historyError}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{versions === null && !historyError && (
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="mt-3 text-xs text-slate-500">กำลังโหลด…</p>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
)}
|
|
|
|
|
|
{versions !== null && versions.length === 0 && (
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<p className="mt-3 text-xs text-slate-500">
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{versions !== null && versions.length > 0 && (
|
|
|
|
|
|
<ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
|
|
|
|
|
|
{versions.map((v) => (
|
|
|
|
|
|
<li key={v.id} className="flex items-center gap-2 py-2">
|
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
|
<span className="block text-xs font-medium">
|
|
|
|
|
|
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
|
|
|
|
|
</span>
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
<span className="block text-[11px] text-slate-500" suppressHydrationWarning>
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
{new Date(v.createdAt).toLocaleString("th-TH")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={restoringId !== null}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
className={`shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => void handleRestore(v.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="mt-3 flex justify-end text-xs">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-16 13:24:33 +07:00
|
|
|
|
autoFocus
|
|
|
|
|
|
className={`rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
onClick={() => setHistoryFor(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
ปิด
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-16 13:24:33 +07:00
|
|
|
|
{/* เมนู ⋯ ของแถวรายการ — render ผ่าน portal เดียวกับ dialog ประวัติ (aside.sticky
|
|
|
|
|
|
สร้าง stacking context ครอบ z ไว้) / attribute data-tool-menu ให้ (1) outside-click
|
|
|
|
|
|
handler ของ PDFViewer ข้ามโหนดใน portal นี้ (2) CSS จอสัมผัส [data-tool-menu]
|
|
|
|
|
|
button ได้ min 36px อัตโนมัติ / lookup เป้าหมายจาก state สดทุก render — หาย
|
|
|
|
|
|
ไปจาก realtime แล้ว effect ด้านบนปิดเมนูให้เอง */}
|
|
|
|
|
|
{menuFor && menuPos && (menuAnn ?? menuBm) && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
data-tool-menu=""
|
|
|
|
|
|
role="menu"
|
|
|
|
|
|
aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"}
|
|
|
|
|
|
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: "fixed",
|
|
|
|
|
|
left: menuPos.left,
|
|
|
|
|
|
top: menuPos.top,
|
|
|
|
|
|
// เหนือ aside overlay (z-50) และ backdrop (z-[45]) ตามเมนูของ tool
|
|
|
|
|
|
zIndex: 60,
|
|
|
|
|
|
// จอเตี้ย (มือถือแนวนอน) เมนูยาวเกินพื้นที่ใต้ปุ่ม → เลื่อนภายในได้
|
|
|
|
|
|
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
|
|
|
|
|
overflowY: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{menuConfirmDelete ? (
|
|
|
|
|
|
<div className="p-1">
|
|
|
|
|
|
<p className="px-1 text-xs text-slate-700">
|
|
|
|
|
|
ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้?
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mt-1.5 flex justify-end gap-1.5">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
onClick={closeRowMenu}
|
|
|
|
|
|
>
|
|
|
|
|
|
ยกเลิก
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
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>
2026-09-16 14:28:53 +07:00
|
|
|
|
className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
|
2026-09-16 13:24:33 +07:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
|
|
|
|
|
|
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
|
|
|
|
|
|
else onDeleteBookmark(menuFor.id);
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
ลบ
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : menuFor.kind === "annotation" && menuAnn ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
Icon={Copy}
|
|
|
|
|
|
label="คัดลอกลิงก์"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
copyAnnotationLink(menuAnn);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* N1: โน้ตคำอธิบาย — ยกเว้น COMMENT ที่มี message ทำหน้าที่นี้แล้ว */}
|
|
|
|
|
|
{menuAnn.userId === userId && menuAnn.type !== "COMMENT" && (
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
Icon={StickyNote}
|
|
|
|
|
|
label={annotationNote(menuAnn) ? "แก้ไขโน้ต" : "เพิ่มโน้ต"}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
startNoteEdit(menuAnn);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* F19/A7: ประวัติ + กู้คืน — เฉพาะรายการของตัวเอง */}
|
|
|
|
|
|
{menuAnn.userId === userId && (
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
Icon={Clock}
|
|
|
|
|
|
label="ดูประวัติการเปลี่ยนแปลง"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
void openHistory(menuAnn);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{menuAnn.userId === userId && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="my-1 border-t border-slate-100" />
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
danger
|
|
|
|
|
|
Icon={Trash2}
|
|
|
|
|
|
label="ลบรายการ"
|
|
|
|
|
|
onClick={() => setMenuConfirmDelete(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : menuBm ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* N2: ดักหน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนี้ — ได้เฉพาะความลึกยังไม่เต็ม 3 ระดับ */}
|
|
|
|
|
|
{bookmarkDepth(menuBm, bookmarkById) < 3 && userId !== "" && (
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
Icon={Plus}
|
|
|
|
|
|
label="เพิ่มหน้าที่ดูอยู่เข้าหัวข้อนี้"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
onAddSubBookmark(menuBm.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{menuBm.userId === userId && (
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
Icon={Pencil}
|
|
|
|
|
|
label="แก้ไขหัวข้อ"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
closeRowMenu();
|
|
|
|
|
|
startBookmarkEdit(menuBm);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{menuBm.userId === userId && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="my-1 border-t border-slate-100" />
|
|
|
|
|
|
<MenuItem
|
|
|
|
|
|
danger
|
|
|
|
|
|
Icon={Trash2}
|
|
|
|
|
|
label="ลบหัวข้อ"
|
|
|
|
|
|
onClick={() => setMenuConfirmDelete(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
</>
|
|
|
|
|
|
,
|
|
|
|
|
|
document.body
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|