N7 รายงานรายการ bookmark เป็น PDF (requirement 5.3.15): route /api/bookmarks/export สร้างเอกสารใหม่ล้วนด้วย PDFDocument.create (ไม่โหลดไฟล์ต้นฉบับเลย — invariant #1) ต้นไม้ indent 3 ระดับเรียงตาม N5 + หัวรายงาน + แบ่งหน้าต่อบรรทัด + footer หน้า n จาก N + cycle guard (seen ร่วมทุก root เติมโหนดนอก root เป็นระดับ 1) + 0 bookmark = หน้าเดียวมีข้อความแจ้ง + ปุ่ม Download ท้ายแถว toggle เงื่อนไขค้นหาในแท็บ "ที่คั่นหน้า" (ลิงก์ธรรมดา pattern F16) + export F8: outline bookmark เรียงตามลำดับหน้าเหมือน sidebar (N5: pageIndex→yOffset→createdAt แทน createdAt เดี่ยว) + ถอด drawNoteBox กลับเป็น sticky note annotation อย่างเดียวตามสเปก §F8 ไม่วาดข้อความ flatten ทับหน้ากระดาษ (กล่องเหลืองแบนทับเนื้อหาเอกสาร — ผู้ใช้ตัดสินใจ; tooltip ตัดไทยใน Chrome เป็น bug ฝั่ง PDFium) + แผง selection ของ COMMENT/REDACTION ที่ไม่มี row สไตล์หุบเป็น w-fit ปุ่ม bookmark/ลบติดกันคั่นเส้นแนวตั้ง
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 49s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 49s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
bc8ba83deb
commit
460cd9c600
7 changed files with 379 additions and 28 deletions
|
|
@ -5,7 +5,7 @@
|
|||
**Judge's Note** — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt)
|
||||
|
||||
- ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark
|
||||
- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N6) อ่านเพิ่ม: `docs/PLAN-FEAT.md`
|
||||
- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N7) อ่านเพิ่ม: `docs/PLAN-FEAT.md`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ app/actions/files.ts ← Server Action uploadFile — validate + เขีย
|
|||
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
||||
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
||||
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
|
||||
app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ)
|
||||
app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||
app/page.tsx ← รายการไฟล์ (server component)
|
||||
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||
|
|
@ -65,7 +66,7 @@ components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล
|
|||
|
||||
## ขอบเขต — ห้ามทำเกิน (Out of Scope)
|
||||
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`)
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`)
|
||||
|
||||
- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
|
||||
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
|
||||
|
|
|
|||
|
|
@ -122,7 +122,8 @@ function attachNoteSticky(
|
|||
|
||||
/**
|
||||
* ฝัง bookmarks เป็น PDF outline (แผง Bookmarks ของ reader กดกระโดดได้) — โครงสร้าง
|
||||
* ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตาม createdAt เหมือน sidebar แต่ละรายการ
|
||||
* ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตามลำดับหน้าเหมือน sidebar (N5 —
|
||||
* ลำดับมาจาก query ด้านผู้เรียก คง relative order ไว้ทั้ง root และ children ในแม่เดียวกัน) แต่ละรายการ
|
||||
* /Dest ชี้หน้า + ตำแหน่ง yOffset — pdf-lib ไม่มี API ระดับสูงสำหรับ outline จึงต่อ
|
||||
* PDFDict เอง (Title เป็น PDFHexString UTF-16BE รองรับภาษาไทย)
|
||||
*/
|
||||
|
|
@ -632,11 +633,16 @@ export async function GET(req: Request) {
|
|||
}
|
||||
|
||||
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
|
||||
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
|
||||
const bookmarkRows = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, fileId))
|
||||
.orderBy(asc(bookmarks.createdAt));
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
);
|
||||
buildOutline(doc, bookmarkRows);
|
||||
|
||||
const out = await doc.save();
|
||||
|
|
|
|||
224
app/api/bookmarks/export/route.ts
Normal file
224
app/api/bookmarks/export/route.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import { PDFDocument, rgb, type PDFFont, type PDFPage } from "pdf-lib";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { bookmarks, files, type BookmarkRow } from "@/db/schema";
|
||||
import { encodeRFC5987, wrapTextByWidth } from "@/utils/pdf-export";
|
||||
|
||||
// รายงานรายการ bookmark เป็น PDF (N7 — PLAN-BOOKMARK-PDF.md / PLAN-FEAT.md §7, requirement 5.3.15)
|
||||
// - สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create) — ไม่โหลดไฟล์เอกสารต้นฉบับเลย
|
||||
// (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป จึงไม่เกี่ยว readPublicPdf)
|
||||
// - ต้นไม้ indent 3 ระดับ เรียงตามลำดับหน้า (N5) เหมือน sidebar — แต่ละรายการ:
|
||||
// ชื่อ + "หน้า N · ผู้บันทึก · วันที่บันทึก" + โน้ตใต้ชื่อ
|
||||
// - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB)
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
|
||||
|
||||
// ─── layout คงที่ของรายงาน (A4 portrait) ───
|
||||
const PAGE_W = 595.28;
|
||||
const PAGE_H = 841.89;
|
||||
const MARGIN = 48;
|
||||
const INDENT_STEP = 14; // indent ต่อระดับของต้นไม้
|
||||
const META_SIZE = 9;
|
||||
const LINE_H = 1.35; // พื้นที่เผื่อสระบน–ล่างของไทย (ไ ใ / ุ ึ)
|
||||
// slate-900 / slate-500 / slate-200 — โทนเดียวกับ sidebar
|
||||
const INK = rgb(15 / 255, 23 / 255, 42 / 255);
|
||||
const MUTED = rgb(100 / 255, 116 / 255, 139 / 255);
|
||||
const RULE = rgb(226 / 255, 232 / 255, 240 / 255);
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
|
||||
if (!UUID_RE.test(fileId)) {
|
||||
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
|
||||
}
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, fileId));
|
||||
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, fileId))
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
);
|
||||
|
||||
// ฟอนต์จำเป็น — รายงานเป็นข้อความล้วน ไม่มี fallback ที่มีความหมาย
|
||||
// (ต่างจาก D5 ของ F8 ที่ annotation ยังวาดรูปได้จึง optional)
|
||||
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
|
||||
if (!fs.existsSync(fontPath)) {
|
||||
console.error("ไม่พบ public/fonts/Sarabun-Regular.ttf");
|
||||
return new Response("ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf", { status: 500 });
|
||||
}
|
||||
const doc = await PDFDocument.create();
|
||||
doc.registerFontkit(fontkit);
|
||||
let font: PDFFont;
|
||||
try {
|
||||
font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
|
||||
} catch (err) {
|
||||
console.error("embed Sarabun ไม่สำเร็จ (รายงาน bookmark)", err);
|
||||
return new Response("ฝังฟอนต์ไม่สำเร็จ สร้างรายงานไม่ได้", { status: 500 });
|
||||
}
|
||||
const widthOf = (s: string, size: number) => font.widthOfTextAtSize(s, size);
|
||||
|
||||
// ต้นไม้ตาม parentId — เดินตามลำดับ array ที่ query เรียงตาม N5 มาแล้ว
|
||||
// (ลูกในแต่ละแม่เรียงตาม หน้า → yOffset → เวลา อัตโนมัติ)
|
||||
// orphan (parentId ไม่อยู่ในชุด) → เลื่อนขึ้น root เหมือน sidebar/buildOutline
|
||||
const ids = new Set(rows.map((b) => b.id));
|
||||
const childrenOf = new Map<string, BookmarkRow[]>();
|
||||
const roots: BookmarkRow[] = [];
|
||||
for (const b of rows) {
|
||||
if (b.parentId && ids.has(b.parentId)) {
|
||||
const list = childrenOf.get(b.parentId) ?? [];
|
||||
list.push(b);
|
||||
childrenOf.set(b.parentId, list);
|
||||
} else {
|
||||
roots.push(b);
|
||||
}
|
||||
}
|
||||
type Item = { row: BookmarkRow; level: number };
|
||||
const items: Item[] = [];
|
||||
// visited กัน DFS วนไม่รู้จบเมื่อข้อมูลเป็นวงจร (defensive — action กันมาแล้ว)
|
||||
// ใช้ seen ชุดเดียวร่วมทุก root: แชร์ subtree ข้าม root ไม่เกิด (ลูกมีแม่เดียว)
|
||||
// เกิดจริงเฉพาะวงจร ซึ่งต้องกันไม่ให้เดินซ้ำ
|
||||
const seen = new Set<string>();
|
||||
const walk = (b: BookmarkRow, level: number): void => {
|
||||
if (seen.has(b.id)) return;
|
||||
seen.add(b.id);
|
||||
items.push({ row: b, level });
|
||||
for (const k of childrenOf.get(b.id) ?? []) walk(k, level + 1);
|
||||
};
|
||||
for (const r of roots) walk(r, 1);
|
||||
// cycle (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action) → โหนดในวงจรไม่มี root เข้าถึง
|
||||
// เติมเป็นรายการระดับ 1 ให้ครบทุกแถว "ข้อมูลไม่หาย" (ต่างจาก buildOutline ที่ drop
|
||||
// เงียบ ๆ — รายงานหายไปเฉย ๆ ยิ่งงงกว่า) — เคส roots ว่างทั้งไฟล์ (วงจรล้วน) ก็
|
||||
// ออกมาแบนทั้งหมดระดับ 1 จากกลไกนี้เอง
|
||||
for (const row of rows) {
|
||||
if (!seen.has(row.id)) items.push({ row, level: 1 });
|
||||
}
|
||||
|
||||
// ─── วาด — y คือ "ขอบล่างของกล่องบรรทัดถัดไป" (baseline = y + ส่วนต่างของ line height)
|
||||
let page: PDFPage = doc.addPage([PAGE_W, PAGE_H]);
|
||||
let y = PAGE_H - MARGIN;
|
||||
|
||||
// เช็คพื้นที่*ต่อบรรทัด* ไม่ใช่ต่อรายการ — บรรทัดเดียวพอกับหน้าเปล่าเสมอ จึงไม่มี
|
||||
// เคสแถวสูงเกินหน้าที่ต้องจัดการพิเศษ (โน้ตยาว ๆ ก็ไหลข้ามหน้าเอง)
|
||||
const advanceLine = (size: number): number => {
|
||||
const lh = size * LINE_H;
|
||||
if (y - lh < MARGIN) {
|
||||
page = doc.addPage([PAGE_W, PAGE_H]);
|
||||
y = PAGE_H - MARGIN;
|
||||
}
|
||||
y -= lh;
|
||||
return y + (lh - size); // baseline ชิดล่างของกล่องบรรทัด (เผื่อ descender ไทย)
|
||||
};
|
||||
const drawTextLine = (
|
||||
text: string,
|
||||
x: number,
|
||||
size: number,
|
||||
color = INK,
|
||||
): void => {
|
||||
// ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง — ถ้าฝัง advanceLine() ใน argument
|
||||
// base ของ method call (`page`) ถูก resolve ก่อน arguments ตาม spec
|
||||
// ทำให้บรรทัดแรกของหน้าใหม่ (ที่ advanceLine เพิ่ง reassign page) ถูกวาด
|
||||
// กลับลงหน้าเก่าซ้อนบรรทัดบนสุด
|
||||
const ty = advanceLine(size);
|
||||
page.drawText(text, { x, y: ty, size, font, color });
|
||||
};
|
||||
|
||||
// หัวรายงาน (หน้าแรกหน้าเดียว) — ชื่อไฟล์ wrap เหมือนรายการ (title ใน DB ยาวได้ถึง 200)
|
||||
drawTextLine("รายการที่คั่นหน้า", MARGIN, 16);
|
||||
for (const line of wrapTextByWidth(file.title, PAGE_W - MARGIN * 2, (s) =>
|
||||
widthOf(s, 12),
|
||||
)) {
|
||||
drawTextLine(line, MARGIN, 12);
|
||||
}
|
||||
const created = new Date().toLocaleDateString("th-TH", {
|
||||
timeZone: "Asia/Bangkok",
|
||||
});
|
||||
drawTextLine(
|
||||
`สร้างเมื่อ ${created} · ${rows.length} รายการ`,
|
||||
MARGIN,
|
||||
META_SIZE,
|
||||
MUTED,
|
||||
);
|
||||
y -= 8;
|
||||
page.drawLine({
|
||||
start: { x: MARGIN, y },
|
||||
end: { x: PAGE_W - MARGIN, y },
|
||||
thickness: 1,
|
||||
color: RULE,
|
||||
});
|
||||
y -= 18;
|
||||
|
||||
if (items.length === 0) {
|
||||
drawTextLine("ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้", MARGIN, 11, MUTED);
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const { row, level } of items) {
|
||||
// เว้นช่องว่างก่อนรายการ — หัวข้อระดับ 1 เว้นกว้างกว่าให้เห็นกลุ่ม
|
||||
// (y ตกหลุดขอบล่างจากการเว้น = advanceLine บรรทัดแรกเปิดหน้าใหม่เอง)
|
||||
y -= first ? 0 : level === 1 ? 12 : 8;
|
||||
first = false;
|
||||
|
||||
const indent = (level - 1) * INDENT_STEP;
|
||||
const x = MARGIN + indent;
|
||||
const maxW = PAGE_W - MARGIN * 2 - indent;
|
||||
const titleSize = level === 1 ? 13 : 11;
|
||||
|
||||
for (const line of wrapTextByWidth(row.title, maxW, (s) =>
|
||||
widthOf(s, titleSize),
|
||||
)) {
|
||||
drawTextLine(line, x, titleSize);
|
||||
}
|
||||
const date = row.createdAt.toLocaleDateString("th-TH", {
|
||||
timeZone: "Asia/Bangkok",
|
||||
});
|
||||
drawTextLine(
|
||||
`หน้า ${row.pageIndex + 1} · ${row.userName || "ไม่ทราบชื่อ"} · ${date}`,
|
||||
x,
|
||||
META_SIZE,
|
||||
MUTED,
|
||||
);
|
||||
if (row.note) {
|
||||
for (const line of wrapTextByWidth(`โน้ต: ${row.note}`, maxW, (s) =>
|
||||
widthOf(s, META_SIZE),
|
||||
)) {
|
||||
drawTextLine(line, x, META_SIZE, MUTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footer เลขหน้า — วาดท้ายเมื่อรู้จำนวนหน้าทั้งหมด (เนื้อหาหยุดที่ ≥ MARGIN ไม่ชน 24pt)
|
||||
const pages = doc.getPages();
|
||||
pages.forEach((p, i) => {
|
||||
const label = `หน้า ${i + 1} จาก ${pages.length}`;
|
||||
p.drawText(label, {
|
||||
x: (PAGE_W - widthOf(label, META_SIZE)) / 2,
|
||||
y: 24,
|
||||
size: META_SIZE,
|
||||
font,
|
||||
color: MUTED,
|
||||
});
|
||||
});
|
||||
|
||||
const out = await doc.save();
|
||||
const filename = encodeRFC5987(`${file.title}-bookmarks.pdf`);
|
||||
return new Response(new Uint8Array(out), {
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16)
|
||||
"content-disposition": `attachment; filename="bookmarks.pdf"; filename*=UTF-8''${filename}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -333,6 +333,10 @@ export default function AnnotationSelectionLayer({
|
|||
// และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง
|
||||
const isFixedTextbox = textboxData?.width !== undefined;
|
||||
|
||||
// แผงมี row ตัวควบคุมสไตล์หรือเหลือแค่ปุ่ม bookmark+ลบ (COMMENT/REDACTION) —
|
||||
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
|
||||
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
|
||||
|
||||
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
||||
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
||||
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
|
||||
|
|
@ -498,11 +502,12 @@ export default function AnnotationSelectionLayer({
|
|||
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
|
||||
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
|
||||
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
|
||||
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */}
|
||||
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก)
|
||||
กรณี w-fit ใช้ 240 เป็น upper bound ต่อ (แคบกว่า w-60 เสมอ — clamp หลวมเกินไม่มีผลร้าย) */}
|
||||
{editable && !disabled && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute w-60 sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
|
||||
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
|
||||
style={{
|
||||
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
|
||||
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
|
||||
|
|
@ -676,8 +681,8 @@ export default function AnnotationSelectionLayer({
|
|||
</PanelRow>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
<div className="flex items-center justify-between">
|
||||
{hasStyleControls && <div className="my-1 border-t border-slate-100" />}
|
||||
<div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}>
|
||||
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -688,6 +693,10 @@ export default function AnnotationSelectionLayer({
|
|||
>
|
||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */}
|
||||
{!hasStyleControls && (
|
||||
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-slate-200" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ลบรายการนี้"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Circle,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
EyeOff,
|
||||
Highlighter,
|
||||
MessageSquare,
|
||||
|
|
@ -1094,24 +1095,36 @@ export default function AnnotationSidebar({
|
|||
{/* แถวสรุปเงื่อนไข — toggle แผงแบบเต็มความกว้าง (เป้าใหญ่จิ้มง่ายบนจอสัมผัส)
|
||||
แสดงเงื่อนไขที่ตั้งอยู่ "เป็นข้อความ" สดตามฟอร์ม ตลอดแม้แผงหุบ/ปิดผลแล้ว —
|
||||
แทน badge ตัวเลขที่เห็นแค่จำนวน / ไม่มีเงื่อนไข = บอกขอบเขตที่ตั้งได้เลย */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={bmShowConditions}
|
||||
onClick={() => setBmShowConditions((v) => !v)}
|
||||
className={`flex w-full 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>
|
||||
<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>
|
||||
{/* N7: ส่งออกรายการ bookmark เป็น PDF — ลิงก์ธรรมดา pattern Export CSV (F16)
|
||||
ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */}
|
||||
<a
|
||||
href={`/api/bookmarks/export?fileId=${fileId}`}
|
||||
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF"
|
||||
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF"
|
||||
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{bmShowConditions && (
|
||||
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
|
||||
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
|
||||
|
|
@ -1456,7 +1469,7 @@ export default function AnnotationSidebar({
|
|||
onToggle={() => togglePageExpanded(pi)}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<ul className="space-y-1">{items.map(renderAnnotationRow)}</ul>
|
||||
<ul className="ml-4 mr-1 space-y-1">{items.map(renderAnnotationRow)}</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
87
docs/PLAN-BOOKMARK-PDF.md
Normal file
87
docs/PLAN-BOOKMARK-PDF.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)
|
||||
|
||||
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก
|
||||
> อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅)
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N7** รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน `/implement-feat N7`
|
||||
|
||||
## Context
|
||||
|
||||
- **5.3.15 (⚠️):** ตอนนี้มี export PDF ฝัง annotation + bookmark เป็น PDF outline (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุป annotation (F16) — แต่ requirement ถามหา **"เอกสาร PDF ที่เป็นรายงานรายการ bookmark"** เอกสารแยกต่างหาก ไม่ใช่ไฟล์ต้นฉบับ
|
||||
- โครงสร้างพร้อมอยู่แล้วทั้งหมด: pdf-lib + fontkit (ใช้ใน F8), ฟอนต์ไทย `public/fonts/Sarabun-Regular.ttf` (embed ได้เลย), `BookmarkDTO` มี `userName` (N6) + โครงสร้างต้นไม้ `parentId` (N2) + ลำดับ N5 (`pageIndex → yOffset → createdAt`), `wrapTextByWidth`/`encodeRFC5987` ใน `utils/pdf-export.ts`
|
||||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-16)
|
||||
|
||||
1. **Entry point:** ปุ่มไอคอนเล็กในแท็บ "ที่คั่นหน้า" ของ sidebar (ใน block ค้นหา N6) — จุดเดียว ไม่วางที่หน้าสรุป F15
|
||||
2. **เลย์เอาต์:** ต้นไม้ indent 3 ระดับ เหมือน sidebar เป๊ะ — เรียงตามลำดับหน้า (N5) แต่ละรายการ: ชื่อ + metadata (หน้า · ผู้บันทึก · วันที่บันทึก) + โน้ตใต้ชื่อ
|
||||
3. **ขอบเขตข้อมูล:** ทุก bookmark ของไฟล์เสมอ — ไม่ผูกกับเงื่อนไขค้นหา N6 ที่ผู้ใช้ทิ้งไว้
|
||||
|
||||
## Design
|
||||
|
||||
### D1 — Route `app/api/bookmarks/export/route.ts` (GET ?fileId=)
|
||||
|
||||
สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()`) — **ไม่โหลด/ไม่แตะไฟล์เอกสารต้นฉบับเลย** (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป — ไม่เกี่ยว `readPublicPdf`):
|
||||
|
||||
- **validate + query** ตาม pattern route export เดิม (F8/F16): `fileId` ผ่าน UUID → ไม่ผ่าน = 400 "fileId ไม่ถูกต้อง" · หา `files` ไม่เจอ = 404 "ไม่พบไฟล์นี้" · query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ `searchBookmarks`/N5)
|
||||
- **ต้นไม้:** build `childrenOf` + `roots` จาก array ที่เรียงแล้ว (เดินตามลำดับ = ลูกเรียงตาม N5 อัตโนมัติ) — orphan (`parentId` ไม่อยู่ใน set) → เลื่อนขึ้น root เหมือน sidebar/buildOutline · **cycle guard** (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action): DFS ด้วย seen ชุดเดียวร่วมทุก root กันเดินซ้ำ/วนไม่รู้จบ แล้วเติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ — ครอบทั้งวงจรปน root ปกติ (ไม่หายเงียบ) และวงจรล้วนทั้งไฟล์ (ออกมาแบนระดับ 1 ทั้งหมด)
|
||||
- **ฟอนต์:** embed `public/fonts/Sarabun-Regular.ttf` ด้วย fontkit (`subset: true` เหมือน F8) — **จำเป็น** (รายงานเป็นข้อความล้วน): ไม่มีไฟล์ = 500 ข้อความชัด "ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf" (ต่างจาก D5 ของ F8 ที่ optional เพราะ annotation ยังวาดรูปได้ — รายงานไม่มีฟอนต์ = ไร้ค่า) · ใช้ **Regular ตัวเดียว** — ลำดับชั้นทำด้วย indent + ขนาด ไม่ใช้ตัวหนา
|
||||
- **เลย์เอาต์** A4 portrait (595.28×841.89pt) margin 48pt:
|
||||
- หัวรายงาน (หน้าแรกหน้าเดียว): "รายการที่คั่นหน้า" 16pt · ชื่อไฟล์ 12pt · "สร้างเมื่อ {วันที่ export} · N รายการ" 9pt สีเทา (#64748b) + เส้นคั่นใต้หัว
|
||||
- แถว bookmark (DFS ตามต้นไม้): ชื่อ — indent 14pt ต่อระดับ, ระดับ 1 = 13pt / ระดับ 2–3 = 11pt, wrap ด้วย `wrapTextByWidth` (ชื่อยาว) → บรรทัด metadata 9pt เทา: "หน้า {pageIndex+1} · {userName || "ไม่ทราบชื่อ"} · {createdAt format วันที่}" → ถ้ามีโน้ต: "โน้ต: …" 9pt wrap ใต้ metadata
|
||||
- วันที่: `toLocaleDateString("th-TH", { timeZone: "Asia/Bangkok" })` (แสดง พ.ศ. อัตโนมัติ — วันที่อย่างเดียว ไม่มีเวลา ต่างจาก CSV ที่เป็น report เชิงอ่าน)
|
||||
- ระยะห่าง 8pt ต่อรายการ · 12pt ก่อนรายการระดับ 1 ที่ไม่ใช่ตัวแรก (แยกกลุ่มหัวข้อให้เห็น)
|
||||
- **แบ่งหน้า:** y เลย bottom margin → `addPage` วาดต่อ (หัวรายงานไม่ซ้ำ) — guard: ถ้าเพิ่งขึ้นหน้าใหม่แล้วแถวยังสูงเกินหน้า วาดต่ออยู่ดี (กัน infinite loop กับโน้ตยาวเกินหน้า) · จบแล้ววาด footer "หน้า n จาก N" กลางล่าง 9pt ทุกหน้า
|
||||
- **0 bookmark:** ยังสร้าง PDF หน้าเดียวพร้อมข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ ทำนายผลได้)
|
||||
- **Response:** `content-type: application/pdf` + `content-disposition: attachment; filename="bookmarks.pdf"; filename*=UTF-8''${encodeRFC5987(file.title + "-bookmarks.pdf")}` (pattern เดียวกับ F8 `-annotated.pdf` / F16 `-summary.csv`)
|
||||
- **Read-only ล้วน:** ไม่ publish event ไม่ `revalidatePath` (เหมือน `searchBookmarks`/route export เดิม)
|
||||
|
||||
### D2 — ปุ่มในแท็บ "ที่คั่นหน้า" ของ sidebar
|
||||
|
||||
`components/sidebar/AnnotationSidebar.tsx` — ใน block ค้นหา N6 (แสดงเฉพาะ `tab === "bookmarks"`):
|
||||
|
||||
- ปุ่มไอคอน `Download` (lucide) `shrink-0` วาดท้ายแถว toggle เงื่อนไข (ถัดจาก chevron) เป็น **`<a href={`/api/bookmarks/export?fileId=${fileId}`}>` ลิงก์ธรรมดา** — ไม่มี JS ไม่มี state (pattern เดียวกับปุ่ม Export CSV หน้าสรุป F16) — `fileId` เป็น prop อยู่แล้ว
|
||||
- `title` + `aria-label` = "ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF" (บอก scope ตามการตัดสินใจ #3 — ไม่ขึ้นกับเงื่อนไขค้นหา) + `FOCUS_RING_CLS` + class `coarse-36` (เป้าจิ้ม 36px บนจอสัมผัส — pattern ปุ่ม sidebar)
|
||||
- แสดงทุกมุมมองของแท็บ (ต้นไม้ / ผลการค้นหา) เพราะ block ค้นหาอยู่คงที่เหนือพื้นที่เนื้อหาเสมอ
|
||||
|
||||
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
|
||||
|
||||
| ไฟล์ | สิ่งที่แก้ |
|
||||
|---|---|
|
||||
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N7 · รายงานรายการ bookmark เป็น PDF** (format ตาม N5/N6 + อ้างแผนฉบับเต็ม `docs/PLAN-BOOKMARK-PDF.md`) + ปรับ out of scope ของ N6 ที่อ้าง gap 5.3.15 ให้ชี้มา N7 |
|
||||
| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N7 + โครงสร้างไฟล์เพิ่ม `app/api/bookmarks/export/route.ts` (รายงาน PDF รายการ bookmark — N7) |
|
||||
|
||||
- `docs/PLAN.md` **ไม่แตะ** (ไม่มี schema/column ใหม่, ไม่มี invariant ใหม่ — เป็น route อ่านอย่างเดียวสร้างไฟล์ใหม่)
|
||||
- `.claude/rules/*.md` **ไม่แตะ** (route read-only ไม่เขียน DB ไม่เกี่ยว data-layer rule ใหม่; ไม่แตะ viewer/coordinates/realtime)
|
||||
|
||||
## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน)
|
||||
|
||||
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|
||||
|---|---|---|---|
|
||||
| 1 | Docs sync N7 | `docs/PLAN-FEAT.md`, `CLAUDE.md` | สเปก §7 ครบตาม format N5/N6; whitelist + โครงสร้างไฟล์มี N7/route ใหม่ |
|
||||
| 2 | Route รายงาน PDF | `app/api/bookmarks/export/route.ts` | Playwright request: ไฟล์ที่มี bookmark ต้นไม้ 3 ระดับ → response `%PDF` + headers (filename* ไทย) + อ่านกลับด้วย pdf-lib ได้ page count ถูก; เนื้อหาตรวจด้วยการเปิดใน pdf.js viewer ชั่วคราว (วิธีเดิมที่ใช้ verify F8); เคส: fileId ไม่ผ่าน = 400, ไม่มีไฟล์ = 404, 0 bookmark = PDF หน้าเดียวข้อความว่าง, `userName = ''` = "ไม่ทราบชื่อ", ชื่อ/โน้ตยาว wrap + ขึ้นหน้าใหม่, footer หน้า n/N |
|
||||
| 3 | ปุ่ม sidebar | `components/sidebar/AnnotationSidebar.tsx` | Playwright: กดปุ่มเกิด download ชื่อไฟล์ถูก; ปุ่มโผล่ทั้งมุมมองต้นไม้และผลค้นหา; จอ touch เป้า 36px; **regression: ค้นหา N6 ยังทำงานครบ 4 เงื่อนไข** |
|
||||
| 4 | ปิดงาน | — | plan-reviewer ผ่านทุก invariant ที่ N7 แตะ (โดยเฉพาะ #1 non-destructive) + code-reviewer + vitest + `tsc` |
|
||||
|
||||
## Edge cases (ออกแบบไว้ล่วงหน้า)
|
||||
|
||||
1. **ชื่อ/โน้ตยาวเกินหน้า** → `wrapTextByWidth` ตัดทีละตัวอักษร (ไทยไม่มีช่องว่าง) + ขึ้นหน้าใหม่; แถวเดียวสูงเกินหน้าเปล่าทั้งหน้า → วาดต่อ (guard infinite loop)
|
||||
2. **orphan `parentId`** (แม่หาย) → เลื่อนขึ้น root เหมือน sidebar และ buildOutline
|
||||
3. **cycle ใน DB** (แก้ตรง ๆ หลุด guard ของ action) → seen ร่วมทุก root กันเดินซ้ำ + โหนดที่ไม่มี root เข้าถึงถูกเติมเป็นระดับ 1 ท้ายรายการ — ทั้งวงจรล้วนและวงจรปน root ปกติ ไม่พัง ไม่ซ้ำ ไม่หาย
|
||||
4. **`userName = ''`** (row เดิมก่อน backfill N6) → "ไม่ทราบชื่อ"
|
||||
5. **ลึกเกิน 3 ระดับใน DB** (แก้ตรง ๆ) → DFS วาดตามจริง indent ต่อเนื่อง — ไม่พัง แค่ยาว (action กัน ≤ 3 อยู่แล้ว)
|
||||
6. **0 bookmark** → PDF หน้าเดียว + "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ปุ่มไม่เคย error)
|
||||
7. **ชื่อไฟล์/ชื่อ bookmark ภาษาไทย + อักขระพิเศษ** → `PDFHexString`/drawText กับ Sarabun embed (pattern เดียวกับ TEXTBOX export F8) + filename `filename*` RFC 5987
|
||||
8. **หน้าเอกสารเกินจริง** (pageIndex พ้น page count ของเอกสาร) → ไม่เกี่ยว — รายงานไม่ผูกเอกสารต้นฉบับ แสดงเลขหน้าจาก DB ตรง ๆ
|
||||
|
||||
## Out of scope (ไม่ทำในแผนนี้)
|
||||
|
||||
- export **ตามเงื่อนไขค้นหา N6** (ผู้ใช้เลือก: ทั้งไฟล์เสมอ)
|
||||
- รวม annotation ในรายงาน / เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV (F15/F16 ทำหน้าที่สรุป annotation อยู่แล้ว)
|
||||
- ตัวหนา (`Sarabun-Bold.ttf`) — ใช้ Regular ตัวเดียว (repo ไม่มีไฟล์ Bold; ลำดับชั้นด้วย indent + ขนาด)
|
||||
- export ข้ามหลายไฟล์ / document set — gap **5.3.10** (กลุ่ม C)
|
||||
- ตัวเลือกหน้ากระดาษ/แนวนอน/config หน้ารายงาน
|
||||
|
||||
## Verification (ภาพรวม end-to-end)
|
||||
|
||||
1. `npm run dev` — เปิดไฟล์ที่มี bookmark ต้นไม้ 3 ระดับกระจัดกระจายหลายหน้า → กดปุ่มในแท็บที่คั่นหน้า → ได้ไฟล์ PDF ชื่อ `{title}-bookmarks.pdf`
|
||||
2. เปิดรายงานตรวจ: ลำดับ = N5 (หน้า → ตำแหน่งบนหน้า → เวลา), indent 3 ระดับ, metadata ครบ หน้า/ผู้บันทึก/วันที่, โน้ตใต้ชื่อ, footer เลขหน้า, ข้อความไทย render ถูกด้วย Sarabun
|
||||
3. Regression: ค้นหา N6 + ต้นไม้ bookmark + export PDF F8 + Export CSV F16 ยังทำงานเหมือนเดิม
|
||||
|
|
@ -326,4 +326,15 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
- **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5
|
||||
- **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer
|
||||
- **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ
|
||||
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV/รายงาน PDF (gap 5.3.15), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
|
||||
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนรายงาน PDF ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
|
||||
|
||||
### N7 · รายงานรายการ bookmark เป็น PDF (2026-09-16)
|
||||
|
||||
> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย)
|
||||
|
||||
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()` — ไม่โหลดไฟล์เอกสารต้นฉบับเลย: invariant #1 โดยการสร้าง ไม่ใช่การก๊อป ไม่เกี่ยว `readPublicPdf`): หัวรายงาน (หน้าแรกหน้าเดียว: "รายการที่คั่นหน้า" + ชื่อไฟล์ + "สร้างเมื่อ {วันที่} · N รายการ" + เส้นคั่น) + **ต้นไม้ indent 3 ระดับ** (indent 14pt/ระดับ; ระดับ 1 = 13pt, 2–3 = 11pt — Regular ตัวเดียว ลำดับชั้นด้วย indent+ขนาด ไม่ใช้ตัวหนา) เรียงตาม N5 (`pageIndex → yOffset → createdAt` — query เรียงมาเลย ลูกเรียงตามเกณฑ์เดียวกันอัตโนมัติ) แต่ละรายการ: ชื่อ (wrap `wrapTextByWidth`) + metadata 9pt เทา "หน้า N · {userName || "ไม่ทราบชื่อ"} · {วันที่บันทึก `toLocaleDateString("th-TH")`}" + โน้ต "โน้ต: …" ใต้ metadata; A4 + แบ่งหน้าต่อบรรทัด (guard แถวสูงเกินหน้า) + footer "หน้า n จาก N" ทุกหน้า; embed `Sarabun-Regular.ttf` **จำเป็น** (รายงานเป็นข้อความล้วน — ไม่มีไฟล์ = 500 ข้อความชัด ต่างจาก D5 ของ F8 ที่ optional); 0 bookmark = PDF หน้าเดียวข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ); response `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.pdf")` (pattern F8/F16)
|
||||
- **ต้นไม้ defensive:** orphan `parentId` → เลื่อนขึ้น root เหมือน sidebar/buildOutline; cycle (ข้อมูลแก้ตรง DB หลุด guard ของ action) → DFS ด้วย **seen ชุดเดียวร่วมทุก root** กันเดินซ้ำ/วนไม่รู้จบ แล้ว**เติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ** (ข้อมูลไม่หาย — รวมเคสวงจรปน root ปกติ และเคสวงจรล้วนทั้งไฟล์ = ออกมาแบนทั้งหมดระดับ 1 จากกลไกเดียวกัน)
|
||||
- **การวาด (ระวัง 2 จุด):** วาดบรรทัดต้อง **advanceLine ก่อนแล้วอ่าน `page` ทีหลัง** — ฝัง `advanceLine()` ใน argument ของ `page.drawText(...)` แล้ว base ของ method call ถูก resolve ก่อน arguments ตาม spec ทำให้บรรทัดแรกของหน้าใหม่ (ที่เพิ่ง reassign `page`) ถูกวาดกลับลง**หน้าเก่า**ซ้อนบรรทัดบนสุด; **ชื่อไฟล์ในหัวรายงาน wrap ด้วย `wrapTextByWidth`** เหมือนรายการ (title ใน DB ยาวได้ถึง 200 ตัวอักษร)
|
||||
- **UI:** ปุ่มไอคอน `Download` ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
|
||||
- **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม)
|
||||
- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, ตัวหนา (`Sarabun-Bold.ttf`), export ข้ามหลายไฟล์ (gap 5.3.10), ตัวเลือกหน้ากระดาษ/แนวนอน/config
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue