export+sidebar: กรองตามรายคนที่เลือกดูครบทั้งจอและ export + REDACTION คงไว้เสมอ (คำขอผู้ใช้ 2026-09-22)
export PDF (F8): - route รับ ?user=<userId> เพิ่ม: กรอง annotation ตาม userId นั้น ยกเว้น REDACTION ติดไปเสมอ (D4 — กล่องดำหาย = เนื้อหาที่ปิดกั้นกลับมาอ่านได้) และ bookmarks → PDF outline กรองตามผู้บันทึกด้วย (หัวข้อแม่โดนกรองแต่ลูกยังอยู่ ลูกขึ้น root ตามตรรกะ buildOutline เดิม) ไม่ส่ง param = ทั้งหมด เหมือนเดิม - ชื่อไฟล์เติมชื่อผู้ใช้ท้าย: <title>-annotated-<ชื่อ>.pdf — ชื่อจาก user_name ที่ mirror ในตาราง (ไม่รับ text จาก client) ผ่าน encodeRFC5987 รองรับชื่อไทย - PDFViewer handleExportPdf: resolve filter ปัจจุบันก่อนยิง route (mine → user.userId) ส่ง &user= เมื่อไม่ใช่ ทุกคน จอ (F4): - visibleAnnotations คง REDACTION ของทุกคนไว้เสมอแม้กรองดูของคนอื่น — สูตรเดียวกับ export: กันเนื้อหาที่ปิดกั้นกลับมาอ่านได้บนจอ (เดิมหายไปตาม filter) และจอตรงกับ ไฟล์ export / ผลค้นหา F14 (visibleSearchResults) ใช้สูตรเดียวกัน - sidebar แท็บ Bookmark กรองตามเจ้าของหมายเหตุเหมือน annotation (เดิม F4 คุมแค่ annotation — bug report 2026-09-22): ต้นไม้ ตามหัวข้อ มุมมอง ตามหน้า ตัวเลข แท็บ และ badge จำนวนลูก ใช้ visibleBookmarks ชุดเดียวกัน — ลูกที่แม่โดนกรองขึ้น root เหมือน export; empty state แยก ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก ออกจากไฟล์ว่างจริง - popup ย้ายหัวข้อ (movableTo) และ bookmarkById ยังเห็นต้นไม้เต็ม — ย้ายไปใต้หัวข้อ ของคนอื่นได้ตามเดิม verify: tsc + Playwright e2e (สร้าง redaction จริงเป็น user1 → สลับ user3 กรองของ ตัวเอง: rect ดำของ user1 ยัง render ครบ, tab Bookmark (10)→(2) และแถวต้นไม้ 2 ตรง DB เป๊ะ, ลบ redaction ทดสอบทิ้งจน DB เหลือ 0) + export จริง 4 filter นับ PDF outline ตรงจำนวน bookmarks ของผู้ใช้นั้นใน DB ทุกแบบ — sync docs/PLAN-FEAT.md §F8 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
9ba8f0a8ec
commit
0aef3ff028
4 changed files with 73 additions and 16 deletions
|
|
@ -14,7 +14,7 @@ import {
|
||||||
type PDFPage,
|
type PDFPage,
|
||||||
type PDFRef,
|
type PDFRef,
|
||||||
} from "pdf-lib";
|
} from "pdf-lib";
|
||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||||
|
|
@ -274,9 +274,21 @@ export async function GET(req: Request) {
|
||||||
.from(annotations)
|
.from(annotations)
|
||||||
.where(eq(annotations.fileId, fileId))
|
.where(eq(annotations.fileId, fileId))
|
||||||
.orderBy(asc(annotations.createdAt));
|
.orderBy(asc(annotations.createdAt));
|
||||||
|
// F4: export ตาม filter รายคนที่กำลังดู (?user=<userId> จาก sidebar — ไม่ส่ง = ทั้งหมด)
|
||||||
|
// REDACTION ติดไปเสมอแม้กรองคนอื่น (D4 — กล่องดำหายไป = เนื้อหาที่ปิดกั้นกลับมาอ่านได้)
|
||||||
|
const userParam = url.searchParams.get("user");
|
||||||
|
const filteredRows = userParam
|
||||||
|
? rows.filter((r) => r.userId === userParam || r.type === "REDACTION")
|
||||||
|
: rows;
|
||||||
|
// ชื่อผู้ใช้เติมท้ายชื่อไฟล์ (mirror ในตาราง — ไม่รับ text จาก client) — กรองแล้ว
|
||||||
|
// ผู้นั้นยังไม่มีรายการเลย = ใช้ userId ตรง ๆ
|
||||||
|
const filterName = userParam
|
||||||
|
? (rows.find((r) => r.userId === userParam && r.userName)?.userName ??
|
||||||
|
userParam)
|
||||||
|
: null;
|
||||||
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
|
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
|
||||||
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
|
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
|
||||||
const dtos = rows
|
const dtos = filteredRows
|
||||||
.map(toAnnotationDTO)
|
.map(toAnnotationDTO)
|
||||||
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||||
|
|
||||||
|
|
@ -634,10 +646,16 @@ export async function GET(req: Request) {
|
||||||
|
|
||||||
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
|
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
|
||||||
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
|
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
|
||||||
|
// กรองตามรายคนเหมือน annotation (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่
|
||||||
|
// ลูกขึ้น root (buildOutline จัดการ parent หายเคสนี้อยู่แล้ว)
|
||||||
const bookmarkRows = await db
|
const bookmarkRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(bookmarks)
|
.from(bookmarks)
|
||||||
.where(eq(bookmarks.fileId, fileId))
|
.where(
|
||||||
|
userParam
|
||||||
|
? and(eq(bookmarks.fileId, fileId), eq(bookmarks.userId, userParam))
|
||||||
|
: eq(bookmarks.fileId, fileId),
|
||||||
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
asc(bookmarks.pageIndex),
|
asc(bookmarks.pageIndex),
|
||||||
asc(bookmarks.yOffset),
|
asc(bookmarks.yOffset),
|
||||||
|
|
@ -646,7 +664,9 @@ export async function GET(req: Request) {
|
||||||
buildOutline(doc, bookmarkRows);
|
buildOutline(doc, bookmarkRows);
|
||||||
|
|
||||||
const out = await doc.save();
|
const out = await doc.save();
|
||||||
const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
|
const filename = encodeRFC5987(
|
||||||
|
`${file.title}-annotated${filterName ? `-${filterName}` : ""}.pdf`,
|
||||||
|
);
|
||||||
return new Response(new Uint8Array(out), {
|
return new Response(new Uint8Array(out), {
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/pdf",
|
"content-type": "application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -1322,11 +1322,15 @@ export default function PDFViewer({
|
||||||
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
|
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
|
||||||
}, [optimisticAnnotations, user]);
|
}, [optimisticAnnotations, user]);
|
||||||
|
|
||||||
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
|
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3) — REDACTION คงไว้เสมอแม้
|
||||||
|
// กรองดูของคนอื่น (D4 สูตรเดียวกับ export: กล่องดำหายจากจอ = เนื้อหาที่ปิดกั้น
|
||||||
|
// กลับมาอ่านได้ และจอจะไม่ตรงกับไฟล์ export ที่คงกล่องไว้)
|
||||||
const visibleAnnotations = useMemo(() => {
|
const visibleAnnotations = useMemo(() => {
|
||||||
if (userFilter === "all") return optimisticAnnotations;
|
if (userFilter === "all") return optimisticAnnotations;
|
||||||
const target = userFilter === "mine" ? user?.userId : userFilter;
|
const target = userFilter === "mine" ? user?.userId : userFilter;
|
||||||
return optimisticAnnotations.filter((a) => a.userId === target);
|
return optimisticAnnotations.filter(
|
||||||
|
(a) => a.userId === target || a.type === "REDACTION",
|
||||||
|
);
|
||||||
}, [optimisticAnnotations, userFilter, user]);
|
}, [optimisticAnnotations, userFilter, user]);
|
||||||
|
|
||||||
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
|
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
|
||||||
|
|
@ -1490,7 +1494,19 @@ export default function PDFViewer({
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportError(null);
|
setExportError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(withBasePath(`/api/annotations/export?fileId=${file.id}`));
|
// F4: export ตาม filter รายคนที่กำลังดู ("mine" resolve เป็น userId จริงก่อนส่ง —
|
||||||
|
// route กรองฝั่ง server + เติมชื่อผู้ใช้ท้ายชื่อไฟล์, REDACTION ติดไปเสมอ)
|
||||||
|
const filterUser =
|
||||||
|
userFilter === "all"
|
||||||
|
? null
|
||||||
|
: userFilter === "mine"
|
||||||
|
? (user?.userId ?? null)
|
||||||
|
: userFilter;
|
||||||
|
const res = await fetch(
|
||||||
|
withBasePath(
|
||||||
|
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
|
|
||||||
|
|
@ -374,13 +374,14 @@ export default function AnnotationSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations
|
/** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations
|
||||||
* ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกับ visibleAnnotations ของ
|
* ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกันกับ visibleAnnotations ของ
|
||||||
* PDFViewer — เปลี่ยนตัวกรองหลังค้นแล้ว ผลกรองทันทีไม่ต้องค้นใหม่ */
|
* PDFViewer (รวม REDACTION ของทุกคนไว้เสมอ — D4) — เปลี่ยนตัวกรองหลังค้นแล้ว
|
||||||
|
* ผลกรองทันทีไม่ต้องค้นใหม่ */
|
||||||
const visibleSearchResults = useMemo(() => {
|
const visibleSearchResults = useMemo(() => {
|
||||||
if (searchResults === null) return null;
|
if (searchResults === null) return null;
|
||||||
if (userFilter === "all") return searchResults;
|
if (userFilter === "all") return searchResults;
|
||||||
const target = userFilter === "mine" ? userId : userFilter;
|
const target = userFilter === "mine" ? userId : userFilter;
|
||||||
return searchResults.filter((a) => a.userId === target);
|
return searchResults.filter((a) => a.userId === target || a.type === "REDACTION");
|
||||||
}, [searchResults, userFilter, userId]);
|
}, [searchResults, userFilter, userId]);
|
||||||
|
|
||||||
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
|
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
|
||||||
|
|
@ -435,9 +436,25 @@ export default function AnnotationSidebar({
|
||||||
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
|
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
|
||||||
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
|
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
|
||||||
|
|
||||||
|
// F4: bookmarks ที่แสดง กรองตามเจ้าของหมายเหตุเหมือน annotation (คำขอผู้ใช้
|
||||||
|
// 2026-09-22 ให้ตรงกับ export ที่กรอง PDF outline ตามผู้บันทึก) — หัวข้อแม่โดนกรอง
|
||||||
|
// แต่ลูกยังอยู่ ลูกขึ้น root (พฤติกรรมเดียวกับ buildOutline ของ export) — ใช้เฉพาะจุด
|
||||||
|
// แสดงผล (ต้นไม้/กลุ่มหน้า/ตัวเลขแท็บ): popup ย้ายหัวข้อ (movableTo) กับ bookmarkById
|
||||||
|
// ยังเห็นต้นไม้เต็ม ย้ายไปใต้หัวข้อของคนอื่นได้ตามเดิม
|
||||||
|
const keptBookmarks =
|
||||||
|
userFilter === "all"
|
||||||
|
? bookmarks
|
||||||
|
: bookmarks.filter(
|
||||||
|
(b) => b.userId === (userFilter === "mine" ? userId : userFilter),
|
||||||
|
);
|
||||||
|
const keptIds = new Set(keptBookmarks.map((b) => b.id));
|
||||||
|
const visibleBookmarks = keptBookmarks.map((b) =>
|
||||||
|
b.parentId && !keptIds.has(b.parentId) ? { ...b, parentId: null } : b,
|
||||||
|
);
|
||||||
|
|
||||||
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
|
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
|
||||||
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
|
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
|
||||||
const bmRootPages = groupByPage(bookmarks.filter((b) => !b.parentId).sort(byPageOrder));
|
const bmRootPages = groupByPage(visibleBookmarks.filter((b) => !b.parentId).sort(byPageOrder));
|
||||||
const annPages = groupByPage(annotations);
|
const annPages = groupByPage(annotations);
|
||||||
|
|
||||||
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
|
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
|
||||||
|
|
@ -715,7 +732,7 @@ export default function AnnotationSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
|
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
|
||||||
const children = bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder);
|
const children = visibleBookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder);
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = children.length > 0;
|
||||||
const collapsed = collapsedIds.has(b.id);
|
const collapsed = collapsedIds.has(b.id);
|
||||||
const editing = editBookmark?.id === b.id;
|
const editing = editBookmark?.id === b.id;
|
||||||
|
|
@ -1027,7 +1044,7 @@ export default function AnnotationSidebar({
|
||||||
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
|
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["bookmarks", `Bookmark (${bookmarks.length})`],
|
["bookmarks", `Bookmark (${visibleBookmarks.length})`],
|
||||||
["annotations", `Annotation (${annotations.length})`],
|
["annotations", `Annotation (${annotations.length})`],
|
||||||
] as const
|
] as const
|
||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
|
|
@ -1318,6 +1335,10 @@ export default function AnnotationSidebar({
|
||||||
<p className="p-4 text-center text-xs text-slate-500">
|
<p className="p-4 text-center text-xs text-slate-500">
|
||||||
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
|
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
|
||||||
</p>
|
</p>
|
||||||
|
) : visibleBookmarks.length === 0 ? (
|
||||||
|
<p className="p-4 text-center text-xs text-slate-500">
|
||||||
|
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
|
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
|
||||||
|
|
@ -1345,7 +1366,7 @@ export default function AnnotationSidebar({
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{roots.map((b) => {
|
{roots.map((b) => {
|
||||||
const childCount = bookmarks.filter(
|
const childCount = visibleBookmarks.filter(
|
||||||
(x) => x.parentId === b.id,
|
(x) => x.parentId === b.id,
|
||||||
).length;
|
).length;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1388,7 +1409,7 @@ export default function AnnotationSidebar({
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
|
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
|
||||||
{bookmarks
|
{visibleBookmarks
|
||||||
.filter((b) => !b.parentId)
|
.filter((b) => !b.parentId)
|
||||||
.sort(byPageOrder)
|
.sort(byPageOrder)
|
||||||
.map((b) => renderBookmarkNode(b, 1))}
|
.map((b) => renderBookmarkNode(b, 1))}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ Index: `(file_id)`
|
||||||
- **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
|
- **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
|
||||||
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
|
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
|
||||||
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
|
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
|
||||||
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270)
|
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270) — เพิ่มพารามิเตอร์ `&user=<userId>` (2026-09-22): export ตาม filter รายคน F4 ที่กำลังดู ("mine" resolve เป็น userId ฝั่ง client ก่อนส่ง) — กรองเฉพาะ annotation ของ userId นั้น **ยกเว้น REDACTION ติดไปเสมอ** (D4) และเติมชื่อผู้ใช้ (จาก `user_name` ที่ mirror ในตาราง) ท้ายชื่อไฟล์ `…-annotated-<ชื่อ>.pdf`; bookmarks outline กรองตามผู้บันทึกด้วย (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่ ลูกขึ้น root
|
||||||
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
|
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
|
||||||
4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup)
|
4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup)
|
||||||
5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์
|
5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue