"use client"; import { useEffect, useMemo, useState } from "react"; import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react"; import { applyTestValues, insertBreakAtSelection, insertParagraphAtSelection, listContentControls, selectContentControl, supportsAllContentControlTypes, type ScannedField, } from "@/wordaddin/services/templateService"; import { getErrorMessage } from "@/wordaddin/services/errors"; import { MessageBar } from "./MessageBar"; /** * แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template * สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking: * 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง * 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที * โดยไม่ต้อง render ผ่าน /api/template/render) * 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง * * state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง * ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น) */ const BTN_PRIMARY = "focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"; const BTN_SECONDARY = "focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"; /** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */ const BTN_ACTION = "focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50"; /** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */ const BTN_INSERT = "focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"; function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) { /** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */ const [fields, setFields] = useState(null); const [scanning, setScanning] = useState(false); const [applying, setApplying] = useState(false); const [insertingParagraph, setInsertingParagraph] = useState(false); const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null); /** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */ const [values, setValues] = useState>({}); const [error, setError] = useState(null); const [warning, setWarning] = useState(null); const [success, setSuccess] = useState(null); /** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning * ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */ const [capabilityWarning, setCapabilityWarning] = useState(null); const busy = scanning || applying || insertingParagraph || insertingBreak !== null; /** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ * Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount * แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */ useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false); }, [busy, onBusyChange]); /** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */ const uniqueTags = useMemo(() => { if (!fields) return []; const seen = new Set(); const tags: string[] = []; for (const field of fields) { const tag = field.tag.trim(); if (tag === "" || seen.has(tag)) continue; seen.add(tag); tags.push(tag); } return tags; }, [fields]); const emptyTagCount = useMemo( () => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0), [fields], ); /** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */ const duplicateTags = useMemo(() => { if (!fields) return []; const counts = new Map(); for (const field of fields) { const tag = field.tag.trim(); if (tag === "") continue; counts.set(tag, (counts.get(tag) ?? 0) + 1); } return Array.from(counts.entries()) .filter(([, count]) => count > 1) .map(([tag]) => tag); }, [fields]); /** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย * แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */ const hasFilledValue = useMemo( () => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""), [uniqueTags, values], ); /** * สแกนเอกสาร + เติมค่าฟอร์ม * - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag * พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว) * - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม * และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ * **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่ * (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน) */ const runScan = async (merge: boolean) => { setScanning(true); setError(null); if (!merge) { setSuccess(null); setWarning(null); } try { const scanned = await listContentControls(); setFields(scanned); // Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล // ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน setCapabilityWarning( supportsAllContentControlTypes() ? null : "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้", ); setValues((previous) => { // สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่ // (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป) const next: Record = {}; const seen = new Set(); for (const field of scanned) { const tag = field.tag.trim(); if (tag === "" || seen.has(tag)) continue; seen.add(tag); next[tag] = merge && tag in previous ? previous[tag] : field.text; } return next; }); } catch (scanError) { setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ")); } finally { setScanning(false); } }; const handleView = async (id: number) => { setError(null); try { await selectContentControl(id); } catch (viewError) { setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ")); } }; /** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */ const handleInsertParagraph = async () => { setInsertingParagraph(true); setError(null); try { await insertParagraphAtSelection(); } catch (insertError) { setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ")); } finally { setInsertingParagraph(false); } }; const handleInsertBreak = async (breakKind: "line" | "page") => { setInsertingBreak(breakKind); setError(null); try { await insertBreakAtSelection(breakKind); } catch (insertError) { setError( getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"), ); } finally { setInsertingBreak(null); } }; const handleApply = async () => { setApplying(true); setError(null); setSuccess(null); setWarning(null); try { const result = await applyTestValues(values); if (result.appliedCount === 0 && result.failures.length === 0) { setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่"); } else { if (result.appliedCount > 0) { setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`); } const problems: string[] = []; if (result.missingTags.length > 0) { problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`); } if (result.failures.length > 0) { const first = result.failures[0]; problems.push( `เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures .map((failure) => failure.tag) .join(", ")}) — ${first.reason}`, ); } if (problems.length > 0) { setWarning( `${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`, ); } } // สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ await runScan(true); } catch (applyError) { setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ")); } finally { setApplying(false); } }; return ( /* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
{error ? {error} : null} {warning ? {warning} : null} {success ? {success} : null} {capabilityWarning ? {capabilityWarning} : null} {/* §1 ตรวจสอบ Content Controls */}

ตรวจสอบ Content Controls

{fields ? {fields.length} ช่อง : null}
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? ( {emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""} {emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""} {duplicateTags.length > 0 ? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag` : ""} ) : null} {!fields && !scanning ? (

กดสแกนเพื่อดูรายการช่องในเอกสาร

) : null} {fields && fields.length === 0 && !scanning ? (

ไม่พบ content control ในเอกสาร

) : null} {fields && fields.length > 0 ? (
    {fields.length > 1 ? : null} {fields.map((field, index) => (
  1. {index + 1}. {field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()} {field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}

    {field.text.trim() === "" ? "(ว่าง)" : field.text}

  2. ))}
) : null} {/* §2 กรอกค่าทดสอบ */} {uniqueTags.length > 0 ? ( <>

กรอกค่าทดสอบ

แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ

{uniqueTags.map((tag, index) => (
setValues((previous) => ({ ...previous, [tag]: event.target.value }))} disabled={busy} />
))}
) : fields ? (

ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้

) : (

สแกนก่อนเพื่อกรอกค่าทดสอบ

)} {/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ: Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}

แทรกย่อหน้า / ตัวแบ่ง

); } export default TemplateToolsPanel;