From 3026e30b3434f7f09871b17e0bac74089878d315 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Mon, 14 Sep 2026 09:57:43 +0700 Subject: [PATCH 01/72] =?UTF-8?q?icon=20=E0=B8=9B=E0=B8=B8=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=20header=20=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88?= =?UTF-8?q?=E0=B8=81=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B8=9A=E0=B8=99=E0=B8=88=E0=B8=AD=E0=B8=AA=E0=B8=B1?= =?UTF-8?q?=E0=B8=A1=E0=B8=9C=E0=B8=B1=E0=B8=AA:=20touch-44=20=E0=B8=9A?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B8=84=E0=B8=B1=E0=B8=9A=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=2044px=20=E0=B9=81=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20icon=20=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=A7=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=E0=B8=B5=20flex=20center=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B3=E0=B9=83=E0=B8=AB=E0=B9=89=20icon=20=E0=B8=8A?= =?UTF-8?q?=E0=B8=B4=E0=B8=94=E0=B8=8B=E0=B9=89=E0=B8=B2=E0=B8=A2=20(?= =?UTF-8?q?=E0=B9=81=E0=B8=81=E0=B9=89=E0=B8=97=E0=B8=B5=E0=B9=88=20media?= =?UTF-8?q?=20query=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=E0=B8=88?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/globals.css b/app/globals.css index d0b394e..e0de124 100644 --- a/app/globals.css +++ b/app/globals.css @@ -90,5 +90,10 @@ header .touch-44 > div > a { min-height: 2.75rem; min-width: 2.75rem; + /* min 44px ทำให้ปุ่ม icon เดี่ยวกว้างเกิน content — ต้อง center icon + ไม่งั้นชิดซ้าย+ลอยตาม baseline (ปุ่ม dropdown มี flex อยู่แล้ว ไม่กระทบ) */ + display: inline-flex; + align-items: center; + justify-content: center; } } From 4934b9d0b388775afd41e4b63a50025d37645e22 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Mon, 14 Sep 2026 09:59:01 +0700 Subject: [PATCH 02/72] =?UTF-8?q?eraser=20=E0=B8=A5=E0=B8=9A=E0=B8=95?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=B2=E0=B8=87=20(STAMP)=20?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=94=E0=B9=89:=20hit-test=20?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20InkLayer.eraseAt=20=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20bbox=20=E0=B8=88=E0=B8=B2=E0=B8=81=20annot?= =?UTF-8?q?ationBBox=20(=E0=B8=AA=E0=B8=B9=E0=B8=95=E0=B8=A3=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=20render)=20=E0=B9=80=E0=B8=8A=E0=B9=87=E0=B8=84=E0=B8=95?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=80?= =?UTF-8?q?=E0=B8=AA=E0=B9=89=E0=B8=99/=E0=B8=A3=E0=B8=B9=E0=B8=9B?= =?UTF-8?q?=E0=B8=97=E0=B8=A3=E0=B8=87=E0=B9=80=E0=B8=9E=E0=B8=A3=E0=B8=B2?= =?UTF-8?q?=E0=B8=B0=E0=B8=95=E0=B8=A3=E0=B8=B2=E0=B8=AD=E0=B8=A2=E0=B8=B9?= =?UTF-8?q?=E0=B9=88=20layer=20=E0=B8=9A=E0=B8=99=20=E2=80=94=20=E0=B8=95?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20tool=20erase=20=E0=B8=84=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=20TextboxStampLayer=20=E0=B8=9B=E0=B8=B4=E0=B8=94=20pointer-ev?= =?UTF-8?q?ents=20=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=88=E0=B8=B6=E0=B8=87=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=81=E0=B8=95?= =?UTF-8?q?=E0=B8=B0=20layer=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- components/pdf/InkLayer.tsx | 26 +++++++++++++++++++++++--- components/pdf/PDFViewer.tsx | 1 + docs/PLAN-FEAT.md | 3 ++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/components/pdf/InkLayer.tsx b/components/pdf/InkLayer.tsx index 37cbfbe..d26a1ce 100644 --- a/components/pdf/InkLayer.tsx +++ b/components/pdf/InkLayer.tsx @@ -20,7 +20,8 @@ import { type Rect, type Size, } from "@/utils/coordinates"; -import { hitTestPath, hitTestShape } from "@/utils/geometry"; +import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry"; +import type { InkStamp } from "./TextboxStampLayer"; /** เส้นวาดที่เก็บใน DB — จุดทุกจุดเป็น % ของหน้า, strokeWidth เป็น % ของความกว้าง */ export type InkStroke = { @@ -79,6 +80,7 @@ export default function InkLayer({ size, strokes, shapes, + stamps, redactions, active, nextStroke, @@ -97,6 +99,8 @@ export default function InkLayer({ strokes: InkStroke[]; /** รูปทรงเดิมจาก DB (พิกัด %) */ shapes: InkShape[]; + /** ตรายางเดิมจาก DB (พิกัด %) — render ที่ TextboxStampLayer แต่ eraser hit-test ที่นี่ */ + stamps: InkStamp[]; /** ปิดพรางเดิมจาก DB (พิกัด %) — วาดทับบังข้อความจริงบนจอ (F8) */ redactions: InkRedaction[]; /** tool วาด/รูปทรง/eraser/redact → รับ pointer events */ @@ -289,11 +293,27 @@ export default function InkLayer({ } } - /** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */ + /** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง/ตรายาง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */ function eraseAt(e: ReactPointerEvent) { if (!onErase || ownUserId === "") return; - const p = pointToPercent(toLocalPx(e), measured); + const lp = toLocalPx(e); + const p = pointToPercent(lp, measured); const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px) + // ตรายาง render อยู่ layer บน (TextboxStampLayer) → เช็คก่อน ให้ตัวบนสุดชนะ + // (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox) + for (const s of stamps) { + if (s.userId !== ownUserId) continue; + const b = annotationBBox({ type: "STAMP", data: s.data }, measured); + if ( + lp.x >= b.x - pad && + lp.x <= b.x + b.width + pad && + lp.y >= b.y - pad && + lp.y <= b.y + b.height + pad + ) { + onErase(s.id); + return; + } + } for (const s of strokes) { if (s.userId !== ownUserId) continue; const threshold = toPx(s.strokeWidthPct, measured.width) / 2 + pad; diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 9ac4d47..d57f2af 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -853,6 +853,7 @@ function PdfPage({ strokes={inkStrokes} shapes={inkShapes} redactions={inkRedactions} + stamps={inkStamps} active={inkActive} nextStroke={{ color: drawStyle.color, diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 2fd2909..550285a 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -139,9 +139,10 @@ Index: `(file_id)` ### F6 · B3 Eraser -- Tool `erase`: InkLayer active (รับ pointer, `touch-action: none`), `pointerdown/move` ทำ hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเองเท่านั้น** (ส่ง `userId` มากับ strokes): +- Tool `erase`: InkLayer active (รับ pointer, `touch-action: none`), `pointerdown/move` ทำ hit-test ตำแหน่ง pointer กับเส้น/รูปทรง/ตรายาง **ของตัวเองเท่านั้น** (ส่ง `userId` มากับ strokes): - DRAW: ระยะจากจุดถึง segment ทุกท่อน ≤ `strokeWidth/2 + 6px` - SHAPE: ระยะถึงเส้นขอบ (stroke) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง) + - STAMP: จุดอยู่ใน bbox ของตรา (`annotationBBox` สูตรเดียวกับ render) + 6px — เช็ค**ก่อน**เส้น/รูปทรงเพราะตรา render อยู่ layer บน (TextboxStampLayer) ให้ตัวบนสุดชนะ - โดน = `deleteAnnotation` ทันที (ลากผ่านหลายเส้น = ลบทีละเส้นระหว่างเคลื่อน) — เส้นของคนอื่นไม่ผ่าน hit-test จึงลบไม่ได้ ### F8 · B4+C1 Redaction + Export PDF (ทำคู่กันเสมอ — D4) From 6ddc35d6d5d14687b1b19498f8a481a27a22606e Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Mon, 14 Sep 2026 11:35:05 +0700 Subject: [PATCH 03/72] =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=20annotation=20=E0=B8=9A=E0=B8=99=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=20PDF=20=E2=86=92=20sidebar=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=9B=E0=B9=80=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=99=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99=20+=20=E0=B8=AA=E0=B8=A3?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=AA=E0=B8=A3=E0=B9=87?= =?UTF-8?q?=E0=B8=88=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=E0=B8=97=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=20+=20=E0=B8=81=E0=B8=B1=E0=B8=99=20duplicate=20=E0=B8=8A?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=A7=E0=B8=82=E0=B8=93=E0=B8=B0=E0=B8=95?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?:=20(1)=20=E0=B8=AA=E0=B9=88=E0=B8=87=20selectedId=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20AnnotationSidebar=20=E2=80=94=20=E0=B9=84?= =?UTF-8?q?=E0=B8=AE=E0=B9=84=E0=B8=A5=E0=B8=95=E0=B9=8C=E0=B9=81=E0=B8=96?= =?UTF-8?q?=E0=B8=A7=20(=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=A5?= =?UTF-8?q?=E0=B8=B4=E0=B8=AA=E0=B8=95=E0=B9=8C=E0=B8=AB=E0=B8=A5=E0=B8=B1?= =?UTF-8?q?=E0=B8=81=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=9C=E0=B8=A5=E0=B8=84?= =?UTF-8?q?=E0=B9=89=E0=B8=99=E0=B8=AB=E0=B8=B2)=20+=20scrollIntoView=20bl?= =?UTF-8?q?ock:nearest=20+=20=E0=B8=AA=E0=B8=A5=E0=B8=B1=E0=B8=9A=E0=B8=A1?= =?UTF-8?q?=E0=B8=B2=E0=B9=81=E0=B8=97=E0=B9=87=E0=B8=9A=20Annotations=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B9=80=E0=B8=AD=E0=B8=87=E0=B8=96?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=81=E0=B8=B3=E0=B8=A5=E0=B8=B1=E0=B8=87?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=20Bookmarks=20(rAF=20?= =?UTF-8?q?=E0=B8=A3=E0=B8=AD=E0=B9=81=E0=B8=96=E0=B8=A7=20render=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87=E0=B8=AA=E0=B8=A5=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B9=81=E0=B8=97=E0=B9=87=E0=B8=9A=E0=B8=84=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=99)=20(2)=20AUTO=5FSELECT=5FTYPES=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=20DRAW/SHAPE/TEXT?= =?UTF-8?q?BOX/STAMP/HIGHLIGHT/REDACTION=20=E0=B9=80=E0=B8=AA=E0=B8=A3?= =?UTF-8?q?=E0=B9=87=E0=B8=88=20=3D=20=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=97=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B5=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20saved.id=20(=E0=B8=81=E0=B8=A3?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=20selection=20+=20sidebar=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=9B=E0=B9=80=E0=B8=A5=E0=B8=A2?= =?UTF-8?q?;=20COMMENT=20=E0=B8=A2=E0=B8=81=E0=B9=80=E0=B8=A7=E0=B9=89?= =?UTF-8?q?=E0=B8=99=E0=B9=80=E0=B8=9E=E0=B8=A3=E0=B8=B2=E0=B8=B0=20compos?= =?UTF-8?q?er/pin=20=E0=B8=97=E0=B8=B3=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7)=20=E2=80=94=20=E0=B9=81=E0=B8=81=E0=B9=89=E0=B8=9B?= =?UTF-8?q?=E0=B8=B1=E0=B8=8D=E0=B8=AB=E0=B8=B2=20"=E0=B8=A7=E0=B8=B2?= =?UTF-8?q?=E0=B8=94=E0=B9=80=E0=B8=AA=E0=B8=A3=E0=B9=87=E0=B8=88=E0=B8=84?= =?UTF-8?q?=E0=B8=A5=E0=B8=B4=E0=B8=81=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80?= =?UTF-8?q?=E0=B8=AA=E0=B9=89=E0=B8=99=20tool=20=E0=B8=A2=E0=B8=B1?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=A7=E0=B8=B2?= =?UTF-8?q?=E0=B8=94=20=20sidebar=20=E0=B8=99=E0=B8=B4=E0=B9=88=E0=B8=87"?= =?UTF-8?q?=20(3)=20=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=AD=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=20row=20=E0=B9=82=E0=B8=9C=E0=B8=A5=E0=B9=88?= =?UTF-8?q?=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=E0=B8=AB=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=20(0=E2=86=922=E2=86=921):=20=E0=B9=80=E0=B8=94=E0=B8=B4?= =?UTF-8?q?=E0=B8=A1=20optimistic=20row=20=E0=B8=81=E0=B8=B1=E0=B8=9A=20ro?= =?UTF-8?q?w=20=E0=B8=88=E0=B8=B2=E0=B8=81=20server=20=E0=B8=84=E0=B8=99?= =?UTF-8?q?=E0=B8=A5=E0=B8=B0=20id=20=E0=B8=97=E0=B8=B3=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B9=89=20SSE=20event=20=E0=B8=A1=E0=B8=B2=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20action=20=E0=B9=80=E0=B8=AA=E0=B8=A3?= =?UTF-8?q?=E0=B9=87=E0=B8=88=20dedupe=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=20=E2=80=94=20=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=99=E0=B8=B5=E0=B9=89=20client=20=E0=B8=AA?= =?UTF-8?q?=E0=B9=88=E0=B8=87=20id=20=E0=B8=82=E0=B8=AD=E0=B8=87=20optimis?= =?UTF-8?q?tic=20row=20=E0=B9=83=E0=B8=AB=E0=B9=89=20createAnnotation=20?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B8=8B=E0=B9=89=E0=B8=B3=20(valid?= =?UTF-8?q?ate=20UUID,=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=AA=E0=B9=88?= =?UTF-8?q?=E0=B8=87=20=3D=20=E0=B8=AA=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B4=E0=B8=A1=E0=B8=AA=E0=B8=B3=E0=B8=AB=E0=B8=A3=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=20redo=20path)=20=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=8A?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99=20dedupe=20=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20id=20=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=95=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=E0=B9=81=E0=B8=95=E0=B9=88=E0=B9=81=E0=B8=A3=E0=B8=81?= =?UTF-8?q?=20=E2=80=94=20sync=20PLAN.md=20=C2=A74=20+=20rules/realtime.md?= =?UTF-8?q?=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=95=E0=B8=A3=E0=B8=87?= =?UTF-8?q?=E0=B8=9E=E0=B8=A4=E0=B8=95=E0=B8=B4=E0=B8=81=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=A1=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/realtime.md | 2 +- app/actions/annotations.ts | 19 +++++++++--- components/pdf/PDFViewer.tsx | 19 ++++++++++++ components/sidebar/AnnotationSidebar.tsx | 37 ++++++++++++++++++++---- docs/PLAN.md | 2 +- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index fcdb359..991b6ef 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -15,6 +15,6 @@ paths: - Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply) - `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client - SSE route: ต้อง cleanup (unsubscribe + close) เมื่อ client ตัดการเชื่อมต่อ (`req.signal` abort / `cancel`) -- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ → duplicate ชั่วขณะที่หายเองเมื่อ optimistic row ถูกถอด — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id** +- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ — `createAnnotation` รับ `id` ที่ client สร้างของ optimistic row มาใช้ซ้ำ (validate เป็น UUID) เพื่อให้ dedupe ด้วย id เดียวกันตั้งแต่แรก **ไม่เกิด duplicate ชั่วขณะ** (row โผล่แล้วหาย) — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id** - pub/sub เป็น in-memory ของ process เดียว — ถ้ามีวันที่ต้องรันหลาย instance ให้เพิ่มตัวกลาง (Redis/LISTEN-NOTIFY) ก่อน อย่าแอบ sync ผ่าน DB polling - **Map ของ listener ใน `lib/annotate-events.ts` ต้องเก็บบน `globalThis`** — module-level state จะถูก duplicate เป็น 2 อินสแตนซ์ใน Next dev (bundle ของ "use server" กับ route handler แยกกัน) แล้ว event ไม่ไหลถึงกัน — **ห้ามเปลี่ยนกลับเป็น module-level Map** diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts index 78da8ea..bea55db 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -40,6 +40,9 @@ export type CreateAnnotationInput = { userName: string; type: AnnotationType; data: AnnotationData; + /** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน + * จะได้ไม่เห็นรายการ duplicate ชั่วขณะ (row ชั่วคราว + row จริงคนละ id) — ไม่ส่ง = สร้างใหม่ */ + id?: string; }; export type CreateBookmarkInput = { @@ -407,11 +410,19 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise([ + "DRAW", + "SHAPE", + "TEXTBOX", + "STAMP", + "HIGHLIGHT", + "REDACTION", +]); + /** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน * แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */ const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( @@ -1550,6 +1561,7 @@ export default function PDFViewer({ applyAnnotation({ kind: "add", row: temp }); try { const saved = await createAnnotation({ + id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย) fileId: file.id, pageIndex, userId: user.userId, @@ -1566,6 +1578,12 @@ export default function PDFViewer({ setAnnotations((prev) => prev.some((a) => a.id === saved.id) ? prev : [...prev, saved], ); + // สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ + // optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง) + if (AUTO_SELECT_TYPES.has(type)) { + setSelectedId(saved.id); + setPreviewData(null); + } // F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป let currentId = saved.id; pushHistory({ @@ -2400,6 +2418,7 @@ export default function PDFViewer({ onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)} onGoToBookmark={goToBookmark} onGoToAnnotation={goToAnnotation} + selectedId={selectedId} /> diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 7738bae..551bc04 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, @@ -132,6 +132,7 @@ export default function AnnotationSidebar({ onUpdateBookmark, onGoToBookmark, onGoToAnnotation, + selectedId, }: { annotations: AnnotationDTO[]; bookmarks: BookmarkDTO[]; @@ -157,9 +158,26 @@ export default function AnnotationSidebar({ ) => void; onGoToBookmark: (b: BookmarkDTO) => void; onGoToAnnotation: (a: AnnotationDTO) => void; + /** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */ + selectedId: string | null; }) { const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); + // คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks) + // แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป + // (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน) + const listRef = useRef(null); + useEffect(() => { + if (!selectedId) return; + if (tab !== "annotations") setTab("annotations"); + const raf = requestAnimationFrame(() => { + listRef.current + ?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }); + return () => cancelAnimationFrame(raf); + }, [selectedId, tab]); + // ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ──── const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); @@ -488,9 +506,12 @@ export default function AnnotationSidebar({ return (
  • -
    +
    {tab === "bookmarks" ? ( bookmarks.length === 0 ? (

    diff --git a/docs/PLAN.md b/docs/PLAN.md index 696d2c6..5517e5e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -150,7 +150,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` - **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน -- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่ +- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด) --- From b3b884c736b435bd9e4ca140861e73ed302a9af9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Mon, 14 Sep 2026 13:07:08 +0700 Subject: [PATCH 04/72] =?UTF-8?q?=E0=B8=88=E0=B8=AD=E0=B8=AA=E0=B8=B1?= =?UTF-8?q?=E0=B8=A1=E0=B8=9C=E0=B8=B1=E0=B8=AA=20(iOS/Android):=20?= =?UTF-8?q?=E0=B9=84=E0=B8=AE=E0=B9=84=E0=B8=A5=E0=B8=95=E0=B9=8C=20commit?= =?UTF-8?q?=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20"=E0=B9=84=E0=B8=AE=E0=B9=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=95=E0=B9=8C"=20=E0=B8=A5=E0=B8=AD=E0=B8=A2=E0=B9=83?= =?UTF-8?q?=E0=B8=95=E0=B9=89=20selection=20+=20=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20handle=20=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=9A=20sel?= =?UTF-8?q?ection=20=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87:=20(1)=20=E0=B8=9A=E0=B8=99=20touch=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=B8=E0=B8=A1=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A2=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=20synthetic=20mouseup=20(=E0=B8=A2=E0=B8=B4=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=20tap)=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B3=E0=B9=83=E0=B8=AB=E0=B9=89=20HIGHLIGHT=20?= =?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=96=E0=B8=B9=E0=B8=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=A5=E0=B8=A2?= =?UTF-8?q?=20=E2=80=94=20=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20"=E0=B9=84=E0=B8=AE?= =?UTF-8?q?=E0=B9=84=E0=B8=A5=E0=B8=95=E0=B9=8C"=20=E0=B8=A5=E0=B8=AD?= =?UTF-8?q?=E0=B8=A2=E0=B9=83=E0=B8=95=E0=B9=89=E0=B8=9A=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B8=AA=E0=B8=B8=E0=B8=94=E0=B8=97?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2=E0=B8=82=E0=B8=AD=E0=B8=87=20select?= =?UTF-8?q?ion=20(=E0=B9=81=E0=B8=9E=E0=B8=97=E0=B9=80=E0=B8=97=E0=B8=B4?= =?UTF-8?q?=E0=B8=A3=E0=B9=8C=E0=B8=99=20Acrobat=20mobile;=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=99=E0=B8=9A=E0=B8=99=E0=B8=96=E0=B8=B9?= =?UTF-8?q?=E0=B8=81=E0=B9=80=E0=B8=A1=E0=B8=99=E0=B8=B9/handles=20?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=20OS=20=E0=B8=A2=E0=B8=B6=E0=B8=94?= =?UTF-8?q?=20=E0=B9=83=E0=B8=81=E0=B8=A5=E0=B9=89=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=9A=E0=B8=A5=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=99=E0=B8=9E?= =?UTF-8?q?=E0=B8=A5=E0=B8=B4=E0=B8=81=E0=B8=82=E0=B8=B6=E0=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B9=80=E0=B8=AB=E0=B8=99=E0=B8=B7=E0=B8=AD=20rect=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A3=E0=B8=81)=20=E0=B9=81=E0=B8=AA=E0=B8=94?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=20pointe?= =?UTF-8?q?r:coarse=20+=20tool=20highlight=20=E0=B9=80=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=20selection=20=E0=B8=AB=E0=B8=A2=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B8=99=E0=B8=B4=E0=B9=88=E0=B8=87=20~400ms=20(debou?= =?UTF-8?q?nce=20=E0=B9=83=E0=B8=99=20effect=20selectionchange=20=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A1=E0=B8=8A=E0=B9=88=E0=B8=A7=E0=B8=87?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=81=20handles)=20(2)=20=E0=B9=81?= =?UTF-8?q?=E0=B8=95=E0=B8=B0=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20commit?= =?UTF-8?q?=20=E0=B8=88=E0=B8=B2=E0=B8=81=20snapshot=20=E0=B9=83=E0=B8=99?= =?UTF-8?q?=20touchSelRef=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=88=20selection=20=E0=B8=AA=E0=B8=94=20=E0=B9=80=E0=B8=9E?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=B0=20iOS=20=E0=B9=80=E0=B8=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A3=E0=B9=8C=20selection=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B5=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B9=81=E0=B8=95=E0=B8=B0=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99=20tap=20=E0=B8=88=E0=B8=B0?= =?UTF-8?q?=E0=B8=96=E0=B8=B6=E0=B8=87=20=E2=80=94=20=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=8B=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1+=E0=B8=A5=E0=B9=89=E0=B8=B2=E0=B8=87=20snaps?= =?UTF-8?q?hot=20=E0=B8=88=E0=B8=B6=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=88?= =?UTF-8?q?=E0=B8=A7=E0=B8=87=20250ms=20(scheduleTouchHide)=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20tap=20=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99?= =?UTF-8?q?=20commit=20=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=9C?= =?UTF-8?q?=E0=B8=B9=E0=B8=81=20commit=20=E0=B8=97=E0=B8=B5=E0=B9=88=20poi?= =?UTF-8?q?nterdown=20(=E0=B9=80=E0=B8=A3=E0=B9=87=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=20click);=20desktop=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=83=E0=B8=8A=E0=B9=89=20mouseup=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=AA=E0=B8=AD=E0=B8=87=20path=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20commitHighlight=20=E0=B8=8A?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=E0=B8=81=E0=B8=B1=E0=B8=99=20+=20=E0=B8=95=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=20lastSelectionTextRef=20(=E0=B8=8A=E0=B8=B7=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=20bookmark=20default)=20=E0=B9=83=E0=B8=99=20debounce?= =?UTF-8?q?=20=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=20mou?= =?UTF-8?q?seup=20(3)=20=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20touch-none?= =?UTF-8?q?=20=E0=B8=97=E0=B8=B5=E0=B9=88=20handle=20=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=208=20=E0=B8=82=E0=B8=AD=E0=B8=87=20Annotati?= =?UTF-8?q?onSelectionLayer=20(=E0=B8=82=E0=B8=AD=E0=B8=9A=204=20+=20?= =?UTF-8?q?=E0=B8=A1=E0=B8=B8=E0=B8=A1=204=20=E2=80=94=20InkLayer=20?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81?= =?UTF-8?q?=E0=B8=A5=E0=B9=89=E0=B8=A7)=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=87=E0=B8=B1=E0=B9=89=E0=B8=99=20browser=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B6=E0=B8=94=20gesture=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20scroll=20=E0=B8=AA=E0=B9=88=E0=B8=87=20pointercance?= =?UTF-8?q?l=20=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=A2=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B9=81=E0=B8=97?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=A5=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=E0=B8=A2=E0=B9=89=E0=B8=B2=E0=B8=A2/=E0=B8=9B=E0=B8=A3?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=82=E0=B8=99=E0=B8=B2=E0=B8=94=20(4)=20?= =?UTF-8?q?sync=20rules/pdf-viewer.md=20=E2=80=94=20verify=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20Playwright=20touch=20emulation:?= =?UTF-8?q?=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B9=82=E0=B8=9C?= =?UTF-8?q?=E0=B8=A5=E0=B9=88=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87=20deboun?= =?UTF-8?q?ce,=20=E0=B9=81=E0=B8=95=E0=B8=B0=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=20HIGHLI?= =?UTF-8?q?GHT=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20(server=20action?= =?UTF-8?q?=20+=20DOM=20+=20sidebar),=20=E0=B8=A7=E0=B8=B2=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=AA=E0=B9=89=E0=B8=99=E0=B8=94=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=A2=E0=B8=99=E0=B8=B4=E0=B9=89=E0=B8=A7=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89,=20handles=209=20=E0=B8=88=E0=B8=B8=E0=B8=94=20touch-?= =?UTF-8?q?action:none=20=E0=B8=84=E0=B8=A3=E0=B8=9A,=20tsc=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/pdf-viewer.md | 3 +- components/pdf/AnnotationSelectionLayer.tsx | 13 +- components/pdf/PDFViewer.tsx | 158 ++++++++++++++++++-- 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 6cc7d4b..22a138c 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -23,8 +23,9 @@ paths: - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `

    ` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight +- **จอสัมผัส (iOS/Android) ไม่มี `mouseup` หลังคลุมข้อความด้วยนิ้ว** (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง + tool highlight → effect `selectionchange` debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน `readTextSelection(pi, { withText: true })`) เก็บใน `touchSelRef` + แสดงปุ่ม "ไฮไลต์" ลอย**ใต้**บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ **snapshot ใน ref เท่านั้น ไม่ใช่ selection สด** เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน `commitHighlight` ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool - **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `