ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว

- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ
  จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้
  ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark
  ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes
  เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง
- ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin
  เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ)
  ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests)
- mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่
- Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath
  หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้
  RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
- คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ
  focus อยู่ใน input/textarea/select/contenteditable)
- STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ
  export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง
  selection (uniform scale + clamp รายแกน)
- sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-11 13:28:35 +07:00
parent 289d735bf3
commit 6fd3a1515f
19 changed files with 890 additions and 53 deletions

View file

@ -1,5 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
@ -376,6 +377,17 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
// ─── Actions ─────────────────────────────────────────────────────────────────
/**
* Invalidate Router Cache ฝั่ง client ของหน้า viewer + หน้าสรุปของไฟล์นี้
* หลัง action เขียน DB สำเร็จ — ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ Next.js
* ยังใช้ RSC payload เก่า (initialAnnotations ไม่รวมรายการใหม่) จนต้อง
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
*/
function revalidateFilePages(fileId: string) {
revalidatePath(`/annotate/${fileId}`);
revalidatePath(`/annotate/${fileId}/summary`);
}
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
@ -394,6 +406,7 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
.returning();
const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
revalidateFilePages(fileId);
return dto;
}
@ -437,6 +450,7 @@ export async function deleteAnnotation(id: string, userId: string): Promise<void
action: "delete",
id: annId,
});
revalidateFilePages(removed.fileId);
}
/**
@ -490,6 +504,7 @@ export async function updateAnnotation(
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(row.fileId, { table: "annotations", action: "update", row: dto });
revalidateFilePages(row.fileId);
return dto;
}
@ -522,6 +537,7 @@ export async function setAnnotationResolved(
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(existing.fileId, { table: "annotations", action: "update", row: dto });
revalidateFilePages(existing.fileId);
return dto;
}
@ -555,6 +571,7 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
.returning();
const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
revalidateFilePages(parent.fileId);
return dto;
}
@ -584,6 +601,7 @@ export async function deleteReply(id: string, userId: string): Promise<void> {
}
if (parent) {
publishAnnotateEvent(parent.fileId, { table: "replies", action: "delete", id: replyId });
revalidateFilePages(parent.fileId);
}
}
@ -708,6 +726,7 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
});
const dto = toBookmarkDTO(row);
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
revalidateFilePages(fileId);
return dto;
}
@ -778,6 +797,7 @@ export async function updateBookmark(input: UpdateBookmarkInput): Promise<Bookma
}
const dto = toBookmarkDTO(row);
publishAnnotateEvent(row.fileId, { table: "bookmarks", action: "update", row: dto });
revalidateFilePages(row.fileId);
return dto;
}
@ -819,6 +839,7 @@ export async function deleteBookmark(id: string, userId: string): Promise<void>
action: "delete",
id: bmId,
});
revalidateFilePages(result.fileId);
// ลูกที่ถูกยกขึ้น root → update event ต่อตัว ให้ client อื่นย้ายตำแหน่งตาม
for (const child of result.children) {
publishAnnotateEvent(result.fileId, {
@ -919,6 +940,7 @@ export async function restoreVersion(
const dto = toAnnotationDTO(result.row);
publishAnnotateEvent(v.fileId, { table: "annotations", action: result.action, row: dto });
revalidateFilePages(v.fileId);
return dto;
}