stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-29 09:56:00 +07:00
parent 45e60330b8
commit 651e354cad
17 changed files with 1699 additions and 332 deletions

View file

@ -96,8 +96,13 @@ import {
HIGHLIGHT_DEFAULT_COLOR,
sanitizePdfText,
SHAPE_DEFAULT_COLOR,
measureStampText,
stampBoxHeight,
stampLayout,
STAMP_COLOR,
STAMP_DEFAULT_SCALE_PCT,
STAMP_FONT_STACK,
STAMP_MAX_LABEL_CHARS,
STAMP_PRESETS,
TEXTBOX_DEFAULT_COLOR,
TEXTBOX_DEFAULT_FONT_SIZE_PCT,
@ -130,6 +135,7 @@ import {
COMMENT_PIN_SIZE_PX,
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
hitTestPath,
hitTestShape,
@ -137,7 +143,7 @@ import {
} from "@/utils/geometry";
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
import AnnotationSelectionLayer, { type AnnotationPatch } from "./AnnotationSelectionLayer";
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
@ -190,7 +196,8 @@ type AnnotationAction =
/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2)
* ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน)
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง
* (editingId/commitEdit ใช้ร่วมกับ STAMP ด้วย — StampEditor แก้คำตรายางผ่าน id เดียวกัน) */
type TextboxUI = {
composer: TextboxComposer | null;
commitComposer: (text: string, segments?: TextboxSegment[]) => void;
@ -505,7 +512,7 @@ function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean {
// ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน
// (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก
// และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน
const box = annotationBBox(a, { width: 100, height: 100 });
const box = annotationBBox(a, { width: 100, height: 100 }, measureStampText);
const padX = (4 / size.width) * 100;
const padY = (4 / size.height) * 100;
return (
@ -635,6 +642,7 @@ function PdfPage({
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onAnnotationPatch,
onSetResolved,
onAnnotationBookmark,
onCreateReply,
@ -691,6 +699,9 @@ function PdfPage({
onPinToggle: (id: string | null) => void;
onAnnotationPreview: (data: AnnotationData | null) => void;
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
onAnnotationPatch: (id: string, patch: AnnotationPatch) => void;
onSetResolved: (id: string, resolved: boolean) => void;
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
@ -1180,6 +1191,7 @@ function PdfPage({
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
onPatch={onAnnotationPatch}
/>
);
})()}
@ -1189,6 +1201,88 @@ function PdfPage({
// ─── Client root ─────────────────────────────────────────────────────────────
/** ผสาน patch ของตัวควบคุมเล็ก (AnnotationPatch ของ AnnotationSelectionLayer) เข้ากับ
* data ตามชนิด (เช็ค field เหมือนตัวควบคุมของแผง: rects = HIGHLIGHT, paths/shape =
* เส้น/รูปทรง, label = STAMP, fontSize = TEXTBOX — AnnotationData ไม่มี field type)
* — เรียกตอนถึงตาของคิว update จึงได้ base ล่าสุดเสมอ (ไม่ใช่ snapshot ตอนกดปุ่ม)
* ชนิดไม่ตรงกับ patch = เจตนาเดิมของตัวควบคุมนั้น → คืน base เปล่า ๆ */
function applyAnnotationPatch(base: AnnotationData, patch: AnnotationPatch): AnnotationData {
switch (patch.kind) {
case "color":
// HIGHLIGHT ("rects") กับ STAMP ("label") = field `color`, เส้น/รูปทรง = `strokeColor`
if ("rects" in base || "label" in base) return { ...base, color: patch.color };
if ("paths" in base || "shape" in base) return { ...base, strokeColor: patch.color };
return base;
case "strokeWidth":
if ("paths" in base) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
return {
...base,
strokeWidth: patch.strokeWidth,
...(base.widths
? {
widths: base.widths.map(
(pw) => (pw / base.strokeWidth) * patch.strokeWidth,
),
}
: {}),
};
}
if ("shape" in base) return { ...base, strokeWidth: patch.strokeWidth };
return base;
case "highlightStyle":
if (!("rects" in base)) return base;
return { ...base, style: patch.style };
case "highlightOpacity": {
if (!("rects" in base)) return base;
// 100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่
const { opacity: _prev, ...rest } = base;
return { ...rest, ...(patch.opacityPct < 100 ? { opacity: patch.opacityPct / 100 } : {}) };
}
case "stampScale": {
if (!("label" in base)) return base;
const scaled = { ...base, scale: patch.scale };
// ตรายางเอียง: ขยายแล้ว AABB ของกรอบหมุนยื่นเกินหน้าได้ (position = มุมก่อนหมุน) —
// เลื่อนกลับเข้าหน้าทันทีเหมือน stampRotation ไม่งั้น validator ปฏิเสธแล้วเด้ง
// ทั้งที่บนจอกรอบยังอยู่ในหน้า (code review 2026-09-29)
return scaled.rotation
? { ...scaled, position: clampStampIntoPage(scaled) }
: scaled;
}
case "stampRotation": {
if (!("label" in base)) return base;
// 0° = ถอด field ทิ้งเหมือนแถวเดิม (schema/validator ถือว่าไม่มี = ไม่เอียง)
const { rotation: _prev, ...rest } = base;
if (patch.rotation === 0) return rest;
// หมุนที่ตำแหน่งชิดขอบแล้ว AABB ของกรอบอาจยื่นเกินหน้า — เลื่อนกลับเข้าทันที
// (validator ตรวจ AABB ไม่ติดลบ ไม่งั้น update โดนปฏิเสธแล้วเด้ง — review 2026-09-28)
return {
...rest,
rotation: patch.rotation,
position: clampStampIntoPage({ ...rest, rotation: patch.rotation }),
};
}
case "textbox":
if (!("fontSize" in base)) return base;
return { ...base, ...patch.fields };
case "label": {
if (!("label" in base)) return base;
const relabeled = { ...base, label: patch.label };
// ข้อความใหม่เปลี่ยนจำนวนบรรทัด/ความสูงกรอบ — เอียงแล้ว AABB ยื่นเกินหน้าได้
// เช่นเดียวกับ stampScale จึง clamp กลับเข้าหน้าทันที (code review 2026-09-29)
return relabeled.rotation
? { ...relabeled, position: clampStampIntoPage(relabeled) }
: relabeled;
}
case "textboxText": {
if (!("fontSize" in base)) return base;
// segments null = เคลียร์ตัวหนาเฉพาะส่วนทั้งก้อน (ไม่มีส่วนตัวหนาเหลืออยู่แล้ว)
const { segments: _prev, ...rest } = base;
return { ...rest, text: patch.text, ...(patch.segments ? { segments: patch.segments } : {}) };
}
}
}
export default function PDFViewer({
file,
initialAnnotations,
@ -1241,6 +1335,11 @@ export default function PDFViewer({
const [stampScalePct, setStampScalePct] = useState(STAMP_DEFAULT_SCALE_PCT);
const [stampPreset, setStampPreset] = useState<string>(STAMP_PRESETS[0]); // F18: preset ตรายาง
const [stampMenuOpen, setStampMenuOpen] = useState(false); // F18: dropdown เลือก preset ตรายาง
// หน้า popup "กำหนดเอง" ในเมนูตรายาง (ข้อความ/สี/ขนาด + preview) — reset กลับหน้า preset ทุกครั้งที่เปิดเมนูใหม่
const [stampCustomMode, setStampCustomMode] = useState(false);
// ปุ่มแถว "กำหนดเอง…" ของหน้ารายการ — ย้อนกลับจากหน้ากำหนดเองแล้วคืน focus มาที่นี่
// (input ถูก unmount พร้อม focus ที่อยู่บนมัน ไม่คืนแล้ว focus ตกไป body — UX audit 2026-09-28)
const stampCustomRowRef = useRef<HTMLButtonElement | null>(null);
const [shapeMenuOpen, setShapeMenuOpen] = useState(false); // F5: dropdown เลือกชนิดรูปทรง
const [drawMenuOpen, setDrawMenuOpen] = useState(false); // dropdown เลือกชนิดปากกา (draw)
const [textboxMenuOpen, setTextboxMenuOpen] = useState(false); // dropdown ตั้งค่ากล่องข้อความ
@ -1368,7 +1467,7 @@ export default function PDFViewer({
const m = new Map<AnnotationDTO["id"], Rect>();
for (const a of visibleAnnotations) {
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }, measureStampText));
}
return m;
}, [visibleAnnotations]);
@ -2131,38 +2230,95 @@ export default function PDFViewer({
});
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
if (!user) return;
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
setPreviewData(null);
await startTransition(async () => {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
// ─── คิว update ต่อรายการ (code review 2026-09-28 รอบสอง) ────────────────────
// จอสัมผัส: blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผง selection ต่อทันที —
// ทั้งสอง event วิ่งก่อน React render optimistic row คอมมิตตัวหลังจึงอ่าน base จาก
// snapshot เก่าจนทับค่าที่เพิ่งเปลี่ยน (และ server action สองตัวแข่งกันไม่เรียงลำดับ)
// — update ทุกตัวของ id เดียวกันจึงเข้าคิวเดียวกัน โดย base คำนวณ**ตอนถึงคิว**จาก
// data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ (pendingDataBaseRef) ไม่ใช่ state ณ ตอน event
const updateChainRef = useRef(new Map<string, Promise<void>>());
const pendingDataBaseRef = useRef(new Map<string, AnnotationData>());
/** แกน update เดิม (optimistic + action + history) — เรียกเมื่อถึงตาของคิวเท่านั้น
* คืน promise ที่ resolve เมื่อ server action จบจริง (resolve ใน finally ข้างใน scope —
* `await startTransition(async …)` ของ React 18 คืน control ตั้งแต่ callback สลับไปรอ
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
return new Promise((resolve) => {
if (!user) return resolve();
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
setPreviewData(null);
startTransition(async () => {
try {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
// จาก state จริงหลัง rollback แทน
pendingDataBaseRef.current.delete(id);
} finally {
resolve();
}
});
try {
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
/** เข้าคิว update ของ id นี้ — make คำนวณ data จาก base ล่าสุดตอนถึงตาของคิว */
function enqueueUpdate(id: string, make: (base: AnnotationData) => AnnotationData) {
const prev = updateChainRef.current.get(id) ?? Promise.resolve();
const run = prev
.catch(() => {}) // action ล้มจัดการใน applyUpdate แล้ว — กัน reject ตกทอดตัดคิวที่เหลือ
.then(() => {
const fromState = annotations.find((a) => a.id === id)?.data;
const base = pendingDataBaseRef.current.get(id) ?? fromState;
if (!base) return;
const data = make(base);
pendingDataBaseRef.current.set(id, data);
return applyUpdate(id, data, base);
});
updateChainRef.current.set(id, run);
const cleanup = () => {
// คิวว่างแล้ว (ไม่มีตัวใหม่แทรกระหว่างรอ) — ทิ้ง base ค้างให้รอบหน้าเริ่มจาก state จริง
if (updateChainRef.current.get(id) === run) {
updateChainRef.current.delete(id);
pendingDataBaseRef.current.delete(id);
}
};
// cleanup ทั้งสองทาง — ถ้า run reject (คำนวณ base/applyUpdate โยน) ก็ต้องไม่ค้างคิวของ
// id นี้ตลอดกาลจนการแก้ไขรายการนี้เงียบไปหมด (code review 2026-09-29)
void run.then(cleanup, cleanup);
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
// (data เต็มจาก drag/preview — ผ่านคิวเดียวกันให้ server action เรียงลำดับเสมอ)
function handleUpdateAnnotation(id: string, data: AnnotationData) {
enqueueUpdate(id, () => data);
}
/** ตัวควบคุมเล็กของแผง selection / editor ข้อความ — patch ผสานกับ base ล่าสุดตอนถึงคิว */
function handlePatchAnnotation(id: string, patch: AnnotationPatch) {
enqueueUpdate(id, (base) => applyAnnotationPatch(base, patch));
}
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
// เพราะ undo ผ่าน updateAnnotation จำกัดเจ้าของ และสเปก F2 ก็ไม่รวม resolve) ──
@ -2790,11 +2946,12 @@ export default function PDFViewer({
setTextboxComposer({ pageIndex, position });
return;
}
// F18/B5: คลิกบนหน้า = วางตรายาง preset ที่เลือกไว้ทันที (สี/ขนาดจากเมนูตั้งค่าของ tool)
// F18/B5: คลิกบนหน้า = วางตรายางคำที่เลือกไว้ทันที (preset หรือคำที่พิมพ์เองจากเมนู —
// สี/ขนาดจากเมนูตั้งค่าของ tool เช่นกัน; ช่องคำว่าง/เว้นวรรคล้วน = preset แรก กัน label ว่าง)
if (tool === "stamp" && user) {
const position = getPointerPercent(e, pageEl);
void handleCreateAnnotation(pageIndex, "STAMP", {
label: stampPreset,
label: stampPreset.trim() || STAMP_PRESETS[0],
position,
scale: stampScalePct,
color: stampColor,
@ -2876,7 +3033,7 @@ export default function PDFViewer({
{ width: r.width, height: r.height },
coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX,
)
: annotationBBox(a, { width: 100, height: 100 });
: annotationBBox(a, { width: 100, height: 100 }, measureStampText);
scrollToBelowHeader(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100);
}
@ -2905,23 +3062,25 @@ export default function PDFViewer({
// ─── Text box (F17/B2) ─────────────────────────────────────────────────────
/** เข้าสู่โหมดแก้ไขข้อความ TEXTBOX ของตัวเอง (double-click — null = ยกเลิก) */
/** เข้าสู่โหมดแก้ไขข้อความ inline (TEXTBOX double-click / STAMP ปุ่ม "แก้ไขข้อความ" — null = ยกเลิก) */
function startTextboxEdit(id: string | null) {
setEditingTextboxId(id);
}
/** บันทึกข้อความที่แก้ — reuse updateAnnotation ของ F1 (เจ้าของเท่านั้น การกันอยู่ที่ handler)
* segments (ตัวหนาเฉพาะส่วน) เก็บเฉพาะเมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
/** บันทึกข้อความที่แก้ (TEXTBOX inline / STAMP ผ่าน StampEditor) — ส่งเป็น patch ให้คิว
* update ผสานกับ data ล่าสุด (สี/ขนาดที่เพิ่งเปลี่ยนในแผงก่อนหน้าไม่ถูกทิ้ง); segments
* (ตัวหนาเฉพาะส่วน) เก็บเฉพาะ TEXTBOX เมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
const current = annotations.find((a) => a.id === id);
if (!current || current.type !== "TEXTBOX") return;
setEditingTextboxId(null);
const current = annotations.find((a) => a.id === id);
if (!current) return;
const hasBold = !!segments?.some((s) => s.bold);
void handleUpdateAnnotation(id, {
...current.data,
text,
segments: hasBold ? segments : undefined,
});
handlePatchAnnotation(
id,
current.type === "STAMP"
? { kind: "label", label: text }
: { kind: "textboxText", text, segments: hasBold ? segments ?? [] : null },
);
}
/** บันทึกกล่องข้อความใหม่จาก composer ณ ตำแหน่งที่คลิก (ค่าทั้งหมดจากเมนูตั้งค่าของ tool) */
@ -3504,9 +3663,19 @@ export default function PDFViewer({
);
}
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — เลือกคำกำหนดเองแล้วปุ่มแสดง
// "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือกอยู่แล้ว)
if (key === "stamp") {
const btnText = stampPreset;
const btnText = STAMP_PRESETS.includes(stampPreset)
? stampPreset
: "กำหนดเอง";
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
const finishCustom = () => {
if (!stampPreset.trim()) setStampPreset(STAMP_PRESETS[0]);
setStampMenuOpen(false);
};
return (
<div key={key} ref={stampMenuRef} className="relative">
<button
@ -3522,7 +3691,10 @@ export default function PDFViewer({
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "stamp" || !stampMenuOpen;
switchTool("stamp");
if (reopen) setStampMenuOpen(true);
if (reopen) {
setStampMenuOpen(true);
setStampCustomMode(false); // เปิดเมนูใหม่ = กลับหน้ารายการ preset
}
}}
>
<Icon className="h-4 w-4" />
@ -3546,44 +3718,182 @@ export default function PDFViewer({
overflowY: "auto",
}}
>
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
// L1: รายการที่เลือก = state "active" ใช้ primary tint เหมือน
// เมนูอื่น (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
{stampCustomMode ? (
<>
{/* ─── หน้ากำหนดเอง: ข้อความ + สี + ขนาด + preview ตรายางตามสัดส่วนจริง ─── */}
<div className="flex items-center gap-1 px-1 pb-1">
{/* ย้อนกลับหน้ารายการ — คืน focus ไปปุ่ม "กำหนดเอง…" หลัง render
หน้า preset (rAF รอหลัง commit) เพราะ input ที่กำลัง focus อยู่
ถูก unmount ไปพร้อมกัน ไม่คืนแล้ว focus ตกไป body (UX audit 2026-09-28) */}
<button
type="button"
title="กลับรายการตรายาง"
aria-label="กลับรายการตรายาง"
className={`rounded-md p-1 text-ink-soft transition hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs font-semibold text-ink">ตรายางกำหนดเอง</span>
</div>
{/* ข้อความ — พิมพ์ live ไปที่ค่าที่เลือก (stampPreset) และ preview ทันที */}
<input
type="text"
aria-label="คำตรายางกำหนดเอง"
placeholder="พิมพ์คำสำหรับตรายาง…"
value={stampPreset}
maxLength={STAMP_MAX_LABEL_CHARS}
spellCheck={false}
autoFocus
onChange={(e) => setStampPreset(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัดของ viewer
if (e.key === "Enter") {
e.preventDefault();
finishCustom();
}
if (e.key === "Escape") {
e.preventDefault();
// ย้อนกลับหน้ารายการ (คำที่พิมพ์คงไว้) + คืน focus ไปปุ่มแถว
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}
}}
// focus: แบบไม่มีเงื่อนไข (ไม่ใช่ focus-visible:) — autoFocus หลัง
// แตะปุ่ม "กำหนดเอง…" ด้วยเมาส์/นิ้ว ไม่ match :focus-visible ใน
// Chromium จนช่องโฟกัสอยู่แต่มองไม่เห็น — รูปแบบเดียวกับ popup
// bookmark + rounded-md 6px ตามสเปก Inputs ของ Genesis (UX audit)
className="mb-2 w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
{/* preview — กรอบ/ฟอนต์/การจัดวาง (ย่อฟอนต์บรรทัดเดียวก่อน คำยาว
ค่อย wrap หลายบรรทัด) สูตรเดียวกับตอน render บนหน้าเป๊ะ (measure
ตัวเดียวกัน) ขนาดตามสัดส่วนจริงบนจอ: ความกว้างหน้าแรก × scale% —
ใหญ่เกินเมนู (w-60) ย่อทั้งชุดเชิงเส้น */}
<div className="mb-2 flex justify-center overflow-hidden rounded border border-line bg-canvas px-2 py-3">
{(() => {
const pageW = pageSizes[0]
? Math.ceil(pageSizes[0].width * scale)
: 595;
const wReal = (pageW * stampScalePct) / 100;
const k = Math.min(1, 192 / wReal);
const w = wReal * k;
const label = stampPreset.trim() || "ตัวอย่าง";
const lay = stampLayout(label, w, measureStampText);
return (
<div
className="flex flex-col items-center justify-center overflow-hidden text-center font-bold"
style={{
width: w,
height: stampBoxHeight(lay, w),
border: `${Math.max(w * 0.02, 1)}px solid ${stampColor}`,
borderRadius: Math.max(w * 0.04, 2),
color: stampColor,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
}}
>
{lay.lines.map((ln, i) => (
<div key={i}>{ln || " "}</div>
))}
</div>
);
})()}
</div>
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
{/* จบเหมือนเลือก preset: ปิดเมนูด้วย setState ตรง (ไม่ผ่าน
markMenuClosed) ให้แตะหน้าวางได้ทันที ไม่โดน guard กลืน tap 300ms */}
<button
type="button"
className={`mb-1 w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-hover ${FOCUS_RING_CLS}`}
onClick={finishCustom}
>
ใช้ตรายางนี้
</button>
</>
) : (
<>
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
// L1: รายการที่เลือก = state "active" ใช้ primary tint เหมือน
// เมนูอื่น (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
{/* เข้าหน้ากำหนดเอง (ข้อความ/สี/ขนาด + preview) — สลับหน้าใน popup เดียวกัน
ไม่ปิดเมนู (tap อยู่บนเมนูเอง) · active เมื่อค่าปัจจุบันเป็นคำกำหนดเอง
· ref ไว้คืน focus ตอนย้อนกลับจากหน้ากำหนดเอง (UX audit 2026-09-28) */}
<button
ref={stampCustomRowRef}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
stampPreset.trim() !== "" &&
!STAMP_PRESETS.includes(stampPreset)
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => setStampCustomMode(true)}
>
<Pencil className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">กำหนดเอง…</span>
{stampPreset.trim() !== "" && !STAMP_PRESETS.includes(stampPreset) && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
</>
)}
</div>,
document.body
)}
@ -4093,6 +4403,7 @@ export default function PDFViewer({
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onAnnotationPatch={handlePatchAnnotation}
onSetResolved={(id, resolved) => {
void handleSetResolved(id, resolved);
}}
@ -4134,7 +4445,7 @@ export default function PDFViewer({
// รายการที่ชี้ถูกเลือกอยู่ (แผง selection F1 เปิดแถวนั้นแล้ว) = ไม่โชว์
// tooltip ซ้อนอีกกล่อง — เจอตอนลากคลุมจบ auto-select โผล่ทันทีที่ตำแหน่งเคอร์เซอร์
if (!hovered || hovered.pageIndex !== i || hovered.id === selectedId) return null;
const box = annotationBBox(hovered, { width: w, height: h });
const box = annotationBBox(hovered, { width: w, height: h }, measureStampText);
const note = annotationNote(hovered);
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
const flipLeft = box.x + box.width + 264 > w;