69 lines
3.4 KiB
TypeScript
69 lines
3.4 KiB
TypeScript
|
|
import type { TextboxSegment } from "@/db/schema";
|
||
|
|
|
||
|
|
// แปลงข้อความ/segments ของกล่องข้อความ (TEXTBOX F17) ไป–กลับระหว่าง model ใน DB
|
||
|
|
// กับ DOM ของ contentEditable ใน TextboxEditor (components/pdf/TextboxStampLayer.tsx)
|
||
|
|
// — แยกเป็น pure DOM util (ไม่แตะ React) เพื่อ unit test กับ jsdom ได้ตาม pattern
|
||
|
|
// tests/hooks/useCustomSelection-dom.test.ts (2026-09-28)
|
||
|
|
|
||
|
|
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
|
||
|
|
export function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
|
||
|
|
const esc = (s: string) =>
|
||
|
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
|
const runs = segments?.length ? segments : [{ text }];
|
||
|
|
return runs
|
||
|
|
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
|
||
|
|
.join("")
|
||
|
|
.replace(/\n/g, "<br>");
|
||
|
|
}
|
||
|
|
|
||
|
|
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ
|
||
|
|
* - ขึ้นบรรทัดใหม่มาจาก 2 แหล่ง: <br> (Enter ถูกดักเป็น insertLineBreak — <br> ล้วน)
|
||
|
|
* และ <div>/<p> ที่ browser สร้างเอง (paste/โหมดแก้ไขของ browser อื่น) — เจอ
|
||
|
|
* block ใหม่ให้ push "\n" *ก่อน* เข้า block จากตำแหน่งข้อความที่ไปก่อนหน้า:
|
||
|
|
* push หลังจบ block แบบเดิมจะตกขอบทั้งรอยต่อ text→div แรก ("abc<div>def</div>"
|
||
|
|
* ของ Chrome กลายเป็น "abcdef") และ block ตัวสุดท้ายของก้อน
|
||
|
|
* - whitespace ท้ายทั้งก้อนถูกตัดออกทั้ง text และ segments ให้ตรงกัน (browser เติม
|
||
|
|
* <br> ปิดท้ายให้ contentEditable จนได้ "\n" ค้างใน DB) */
|
||
|
|
export function serializeEditor(root: HTMLElement): {
|
||
|
|
text: string;
|
||
|
|
segments: TextboxSegment[];
|
||
|
|
} {
|
||
|
|
const segs: TextboxSegment[] = [];
|
||
|
|
const push = (text: string, bold: boolean) => {
|
||
|
|
if (!text) return;
|
||
|
|
const last = segs[segs.length - 1];
|
||
|
|
if (last && !!last.bold === bold) last.text += text;
|
||
|
|
else segs.push({ text, ...(bold ? { bold: true } : {}) });
|
||
|
|
};
|
||
|
|
const walk = (node: Node, bold: boolean) => {
|
||
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
||
|
|
push(node.nodeValue ?? "", bold);
|
||
|
|
} else if (node.nodeName === "BR") {
|
||
|
|
push("\n", bold);
|
||
|
|
} else {
|
||
|
|
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
|
||
|
|
if (
|
||
|
|
/^(DIV|P)$/.test(node.nodeName) &&
|
||
|
|
segs.length > 0 &&
|
||
|
|
!segs[segs.length - 1].text.endsWith("\n")
|
||
|
|
) {
|
||
|
|
push("\n", b);
|
||
|
|
}
|
||
|
|
node.childNodes.forEach((c) => walk(c, b));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
walk(root, false);
|
||
|
|
const joined = segs.map((s) => s.text).join("");
|
||
|
|
let cut = joined.length - joined.trimEnd().length;
|
||
|
|
for (let i = segs.length - 1; i >= 0 && cut > 0; i--) {
|
||
|
|
if (segs[i].text.length <= cut) {
|
||
|
|
cut -= segs[i].text.length;
|
||
|
|
segs.splice(i, 1);
|
||
|
|
} else {
|
||
|
|
segs[i].text = segs[i].text.slice(0, segs[i].text.length - cut);
|
||
|
|
cut = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { text: joined.trimEnd(), segments: segs };
|
||
|
|
}
|