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

@ -1,13 +1,24 @@
import { describe, expect, it } from "vitest";
import type { AnnotationData, ShapeData } from "@/db/schema";
import {
STAMP_FONT_RATIO,
STAMP_HEIGHT_RATIO,
STAMP_MAX_LINES,
STAMP_TEXT_WIDTH_RATIO,
annotationBBox,
clampStampIntoPage,
hitTestPath,
hitTestShape,
rotatePoint,
rotatedRectAABB,
scaleAnnotationData,
stampBoxHeight,
stampFitFontSize,
stampLayout,
stampRect,
textboxMaxWidthPct,
translateAnnotationData,
wrapStampLabel,
} from "@/utils/geometry";
// เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง
@ -251,6 +262,69 @@ describe("annotationBBox", () => {
});
});
describe("stampLayout / wrapStampLabel / stampBoxHeight — ตรายางหลายบรรทัด (2026-09-28)", () => {
// measure จำลองเชิงเส้น: อักษรกว้าง 0.6 เท่าของฟอนต์ — เพียงพอต่อการตรวจสูตร
const measure = (t: string, fs: number) => t.length * fs * 0.6;
const w = 100;
it("คำสั้น = บรรทัดเดียวฟอนต์เต็ม กรอบสูง 0.4w เหมือนก่อนมีการ wrap", () => {
const lay = stampLayout("สำคัญ", w, measure);
expect(lay.lines).toEqual(["สำคัญ"]);
expect(lay.fontSize).toBe(w * STAMP_FONT_RATIO);
expect(stampBoxHeight(lay, w)).toBeCloseTo(w * STAMP_HEIGHT_RATIO);
});
it("คำยาว = wrap หลายบรรทัด ฟอนต์ใหญ่กว่าการย่อบรรทัดเดียว กรอบสูงขึ้น", () => {
const label = "สำเนาถูกต้อง ใช้เอกสารฉบับนี้แทนต้นฉบับได้";
const lay = stampLayout(label, w, measure);
expect(lay.lines.length).toBeGreaterThan(1);
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
expect(lay.fontSize).toBeGreaterThan(stampFitFontSize(label, w, measure));
expect(stampBoxHeight(lay, w)).toBeGreaterThan(w * STAMP_HEIGHT_RATIO);
// ทุกบรรทัดกว้างไม่เกินขอบใน
for (const ln of lay.lines) {
expect(measure(ln, lay.fontSize)).toBeLessThanOrEqual(
w * STAMP_TEXT_WIDTH_RATIO + 1e-9,
);
}
});
it("คำยาวพอย่อได้ (30 ตัว) = ฟอนต์หยุดที่ค่าที่ให้บรรทัดเข้าเกณฑ์ STAMP_MAX_LINES", () => {
const lay = stampLayout("ก".repeat(30), w, measure);
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
expect(lay.lines.join("")).toBe("ก".repeat(30));
});
it("คำยาวเกินไป (50 ตัว) = หยุดย่อที่ฟอนต์ระดับ floor แลกกับบรรทัดเกิน (อ่านออกกว่าฟอนต์จิ๋ว)", () => {
const lay = stampLayout("ก".repeat(50), w, measure);
// STAMP_FONT_FLOOR_RATIO = 0.1 — ฟอนต์ไม่ตกต่ำกว่านี้แม้บรรทัดจะเกินเกณฑ์
expect(lay.fontSize).toBeGreaterThanOrEqual(w * 0.1 - 1e-9);
expect(lay.lines.join("")).toBe("ก".repeat(50));
});
it("wrapStampLabel: แยกตามช่องว่าง ส่วนคำเดี่ยวยาวเกินตัดที่ตัวอักษร", () => {
// อักษรกว้างเท่าฟอนต์เลย (measure ตรง) บรรทัดกว้าง 24 → "ab cd" แยกสองบรรทัด
expect(wrapStampLabel("ab cd", 10, 24, (t, fs) => t.length * fs)).toEqual([
"ab",
"cd",
]);
// "abcdef" (6×10=60 > 24) ไม่มีช่องว่าง — ตัดที่อักษร บรรทัดละ 2 ตัว = 3 บรรทัด ครบทุกตัว
const cut = wrapStampLabel("abcdef", 10, 24, (t, fs) => t.length * fs);
expect(cut).toEqual(["ab", "cd", "ef"]);
});
it("annotationBBox STAMP คำยาวสูงเกิน 0.4w (ประมาณด้วยความกว้างต่ออักษร)", () => {
const bbox = annotationBBox(
{
type: "STAMP",
data: { label: "ก".repeat(30), position: { x: 5, y: 5 }, scale: 20 },
},
S,
);
expect(bbox.height).toBeGreaterThan(20 * STAMP_HEIGHT_RATIO);
});
});
describe("translateAnnotationData", () => {
const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [
{
@ -452,3 +526,137 @@ describe("hitTestShape", () => {
expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false);
});
});
describe("annotationBBox STAMP — measure param (ขอบล่างกรอบเกินตรายาง 2026-09-28)", () => {
const S = { width: 100, height: 100 }; // % = px โดยตรง
// 10 อักษร กว้าง w=18: base=3.96, maxW=16.2 — ตามฟอนต์ของแต่ละเครื่องอาจ 1 หรือ 2 บรรทัด
const data = { label: "ตรวจสอบแล้ว", position: { x: 10, y: 20 }, scale: 18 };
const minH = 18 * STAMP_HEIGHT_RATIO; // 0.4w — สูงขั้นต่ำของคำสั้น 1 บรรทัด
it("ส่ง measure ที่ข้อความแคบพอ 1 บรรทัด → สูง = 0.4w เหมือนที่ StampItem render จริง", () => {
const narrow = (text: string, fs: number) => text.length * fs * 0.3;
const box = annotationBBox({ type: "STAMP", data }, S, narrow);
expect(box.x).toBeCloseTo(10);
expect(box.width).toBeCloseTo(18);
expect(box.height).toBeCloseTo(minH);
});
it("คำพอดี ๆ ที่ต้องย่อฟอนต์นิดเดียว = บรรทัดเดียว (ไม่ตก 2 บรรทัด) — กติกา 2026-09-28 รอบสอง", () => {
// ฟอนต์ไทยจริงเฉลี่ย ~0.45/อักษร: "ตรวจสอบแล้ว" ย่อเหลือ ~91% ของ base ก็พอบรรทัดเดียว
const real = (text: string, fs: number) => text.length * fs * 0.45;
const lay = stampLayout(data.label, 18, real);
expect(lay.lines).toEqual([data.label]);
expect(lay.fontSize).toBeLessThan(18 * STAMP_FONT_RATIO); // ย่อจริง
expect(lay.fontSize).toBeGreaterThanOrEqual(18 * 0.1); // ไม่ต่ำกว่า floor
const box = annotationBBox({ type: "STAMP", data }, S, real);
expect(box.height).toBeCloseTo(minH);
});
it("คำยาวเกิน floor (ย่อไม่ช่วย) = wrap หลายบรรทัด สูงเกิน 0.4w — ใช้ measure ที่ส่งเข้ามาจริง", () => {
const wide = (text: string, fs: number) => text.length * fs * 1.2;
const box = annotationBBox({ type: "STAMP", data }, S, wide);
expect(box.height).toBeGreaterThan(minH + 1);
// ไม่ส่ง measure (estimate ฝั่ง server) ก็เข้าเส้นทาง wrap เหมือนกันกับคำยาวระดับนี้
const longData = { ...data, label: "ก".repeat(30) };
const est = annotationBBox({ type: "STAMP", data: longData }, S);
expect(est.height).toBeGreaterThan(minH + 1);
});
});
describe("ตรายางเอียง — rotatePoint / stampRect / rotatedRectAABB (2026-09-28)", () => {
it("rotatePoint: 0° = จุดเดิม, 90° ตามเข็มรอบ origin แกน x ไปแกน y", () => {
const p = { x: 10, y: 0 };
expect(rotatePoint(p, { x: 0, y: 0 }, 0)).toEqual(p); // early return ตรง ๆ
const q = rotatePoint(p, { x: 0, y: 0 }, 90);
expect(q.x).toBeCloseTo(0); // cos(90°) มี float ตกค้าง ตรวจแบบใกล้เคียง
expect(q.y).toBeCloseTo(10);
// ย้อนหมุนกลับตำแหน่งเดิม
const back = rotatePoint(rotatePoint(p, { x: 0, y: 0 }, 37), { x: 0, y: 0 }, -37);
expect(back.x).toBeCloseTo(p.x);
expect(back.y).toBeCloseTo(p.y);
});
it("stampRect: คืนกรอบก่อนหมุน + rotation ของ data (ไม่มี field = 0)", () => {
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
const r = stampRect(data, S);
expect(r).toMatchObject({ x: 10, y: 20, width: 18, rotation: 0 });
const tilted = stampRect({ ...data, rotation: -30 }, S);
expect(tilted).toMatchObject({ x: 10, y: 20, width: 18, rotation: -30 });
// กรอบก่อนหมุนขนาดเท่ากันไม่ว่าจะเอียงเท่าไหร่ (หมุนทีหลังรอบ center)
expect(tilted.height).toBeCloseTo(r.height);
});
it("rotatedRectAABB: 90° สลับกว้าง-สูงรอบ center เดิม, 45° = แนวทะเลียบใหญ่สุด", () => {
const r = { x: 10, y: 20, width: 100, height: 50, rotation: 90 };
const a = rotatedRectAABB(r);
expect(a.width).toBeCloseTo(50);
expect(a.height).toBeCloseTo(100);
expect(a.x + a.width / 2).toBeCloseTo(60); // center คงเดิม
expect(a.y + a.height / 2).toBeCloseTo(45);
const b = rotatedRectAABB({ ...r, rotation: 45 });
expect(b.width).toBeCloseTo((100 + 50) / Math.SQRT2);
expect(b.height).toBeCloseTo((100 + 50) / Math.SQRT2);
});
it("annotationBBox STAMP เอียง = AABB ของกรอบหมุน (ไม่ใช่กรอบก่อนหมุน)", () => {
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 20, rotation: 90 };
const flat = annotationBBox({ type: "STAMP", data: { ...data, rotation: undefined } }, S);
const tilted = annotationBBox({ type: "STAMP", data }, S);
expect(tilted.width).toBeCloseTo(flat.height);
expect(tilted.height).toBeCloseTo(flat.width);
expect(tilted.x + tilted.width / 2).toBeCloseTo(flat.x + flat.width / 2);
});
it("translate/scale ของ STAMP เอียงยังเก็บ rotation ไปด้วย (spread ...data)", () => {
const data: AnnotationData = {
label: "สำคัญ",
position: { x: 10, y: 20 },
scale: 18,
rotation: -15,
};
expect(translateAnnotationData(data, 5, 5)).toMatchObject({ rotation: -15 });
expect(scaleAnnotationData(data, { x: 0, y: 0 }, 2, 2)).toMatchObject({
rotation: -15,
scale: 36,
});
});
});
describe("clampStampIntoPage — ตรายางเอียงชิดขอบหมุนแล้วเลื่อนกลับเข้าหน้า (2026-09-28)", () => {
const mk = (position: { x: number; y: number }, rotation: number) => ({
label: "สำคัญ",
position,
scale: 20,
rotation,
});
it("กลางหน้า (หมุนแล้วยังอยู่ใน 0–100) = position เดิมเป๊ะ", () => {
const d = mk({ x: 40, y: 40 }, 45);
expect(clampStampIntoPage(d)).toEqual(d.position);
});
it("ชิดขอบบน + หมุน 45° (AABB ล้นบน) → เลื่อนลงเข้าหน้า อีกแกนไม่ขยับ", () => {
// w=20 h=8 (คำสั้น estimate) rot 45 → AABB 19.8×19.8 รอบ center (11, 5) → y เริ่ม −4.9
const out = clampStampIntoPage(mk({ x: 1, y: 1 }, 45));
expect(out.y).toBeGreaterThan(1);
expect(out.x).toBe(1); // AABB x ≈ 1.1 ไม่ล้น → แกน x ไม่ขยับ
const aabb = rotatedRectAABB(
stampRect({ label: "สำคัญ", position: out, scale: 20, rotation: 45 }, S),
);
expect(aabb.y).toBeGreaterThanOrEqual(-1e-9);
});
it("ชิดขอบขวา + หมุน 90° → เลื่อนซ้ายกลับเข้าหน้า (แกน x ลด)", () => {
// w=20 h=8 rot 90 → AABB 8×20 รอบ center (100, 44) → x+w = 104 เกินขอบขวา 4
const d = mk({ x: 90, y: 40 }, 90);
const out = clampStampIntoPage(d);
expect(out.x).toBeLessThan(d.position.x);
const aabb = rotatedRectAABB(stampRect({ ...d, position: out }, S));
expect(aabb.x + aabb.width).toBeLessThanOrEqual(100 + 1e-9);
});
it("ไม่หมุน (0°) = พฤติกรรม clamp rect เดิม: ในหน้าอยู่แล้วไม่ขยับ", () => {
const d = mk({ x: 10, y: 10 }, 0);
expect(clampStampIntoPage(d)).toEqual(d.position);
});
});