/* global Word, Office */ /** * แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls * ฝั่ง Office.js ล้วน ไม่ยิง backend: สแกนรายช่อง / กระโดดไปเลือกช่อง / เขียนค่าทดสอบกลับ * เข้าเอกสาร / แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้าที่ cursor * * API ที่ใช้เป็น WordApi 1.1 เกือบทั้งหมด (ยืนยันจาก @types/office-js 1.0.612) ยกเว้น * getByIdOrNullObject (1.3) — ไม่เกินความต้องการต่ำสุดของ manifest (1.3) จึงใช้ได้ทุก platform * รวม Office 2019 และ Word on the web * * ⚠️ collection `contentControls` ธรรมดามองเห็น**เฉพาะ rich text CC** (docstring ของ * Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น * plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ) * ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3 * ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน) * * กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน * browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume * auto-save คู่กันเสมอ และเรียก notifyDocumentChanged() หลังสำเร็จให้ debounce และ flag FR-3 * ทำงานถูกต้อง (pattern เดียวกับ versionFlow.ts) */ import { autoSaveService } from "./autoSaveService"; import { getDetailedErrorMessage } from "./errors"; /** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js * เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */ export interface ScannedField { id: number; /** tag ดิบไม่ trim — เพื่อให้ UI เตือน "tag ว่าง / มี space นำ-ท้าย" ได้ (ตอนเทียบ/ใช้งานคือ trim เสมอ) */ tag: string; title: string; text: string; /** enum ดิบ เช่น "RichTextInline" — ไว้ debug ใน tooltip */ type: string; /** heuristics หยาบจาก enum type: ลงท้ายด้วย "Inline" = ในบรรทัด ไม่งั้น = บล็อก */ kind: "inline" | "block"; } function toScannedField(control: Word.ContentControl): ScannedField { const type = control.type ?? ""; return { id: control.id, tag: control.tag ?? "", title: control.title ?? "", text: control.text ?? "", type, kind: type.endsWith("Inline") ? "inline" : "block", }; } /** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น * (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */ export function supportsAllContentControlTypes(): boolean { try { return Office.context.requirements.isSetSupported("WordApi", "1.5"); } catch { return false; } } /** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด) * ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */ function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection { return supportsAllContentControlTypes() ? context.document.getContentControls() : context.document.contentControls; } /** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error * อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */ export async function listContentControls(): Promise { return Word.run(async (context) => { const controls = supportedContentControls(context); controls.load("id, tag, title, text, type"); await context.sync(); return controls.items.map(toScannedField); }); } /** เลือก + เลื่อน Word ไปที่ช่องตาม id ของผลสแกน — select() ตาม docstring จะ scroll ให้เอง * ไม่ใช่การแก้เอกสาร (document.saved ไม่ dirty) จึงไม่ต้อง suspend */ export async function selectContentControl(id: number): Promise { await Word.run(async (context) => { const control = supportedContentControls(context).getByIdOrNullObject(id); control.load("id"); await context.sync(); if (control.isNullObject) { throw new Error("ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่"); } control.select(); await context.sync(); }); } /** แทรกย่อหน้าว่างใหม่หลังตำแหน่ง cursor (เหมือนกด Enter) — ปุ่มเสริมตอนจัดโครงสร้าง template * (คำขอผู้ใช้ 2026-10-19: ตอนแก้ template ต้องเพิ่มย่อหน้าบ่อย แล้วเปิด formatting marks ตรวจ — * ส่วนการเปิด/ปิด formatting marks เป็น application setting ที่ Office.js ไม่มี API ให้แตะ) */ export async function insertParagraphAtSelection(): Promise { await autoSaveService.suspend(); try { await Word.run(async (context) => { context.document.getSelection().insertParagraph("", "After"); await context.sync(); }); autoSaveService.notifyDocumentChanged(); } finally { autoSaveService.resume(); } } /** page break แบบ paragraph สมบูรณ์ (block-level) — w:p ครอบ run ที่มี * ใช้เป็นวิธีสำรองเมื่อ insertBreak("Page") โดน E_FAIL (0x80004005) ในบางบริบท เช่น cursor * อยู่ในตาราง (เคสจริงบน T3.docx 2026-10-19 — template ของศาลมีตารางเป็นโครงหลัก) · ระดับ fragment * ต้องเป็น block-level เต็มตัว ไม่ใช่ run เดี่ยว (ลองแล้วยัง E_FAIL อีกรอบ) */ const PAGE_BREAK_PARAGRAPH_OOXML = ''; /** แทรกตัวแบ่งที่ตำแหน่ง cursor — "line" = ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter) สำหรับ * จัดที่อยู่/รายการให้ตรงแนว, "page" = ขึ้นหน้าใหม่ (Ctrl+Enter) สำหรับเอกสารหลายหน้า (คำขอผู้ใช้ 2026-10-19) */ export async function insertBreakAtSelection(breakKind: "line" | "page"): Promise { await autoSaveService.suspend(); try { if (breakKind === "line") { await Word.run(async (context) => { context.document.getSelection().insertBreak("Line", "After"); await context.sync(); }); } else { // วิธีมาตรฐานก่อน — ถ้า host ปฏิเสธ (E_FAIL ในบางบริบท) สลับไป OOXML block-level // (sync ที่พัง = คำสั่งถูกปฏิเสธ ไม่มีอะไรถูกเขียนค้าง จึง retry ได้; ถ้า retry ก็พัง // โยน error ที่รวมทั้งสองสาเหตุ ไม่กลืนต้นเหตุแรกหาย) let firstError: unknown; try { await Word.run(async (context) => { context.document.getSelection().insertBreak("Page", "After"); await context.sync(); }); autoSaveService.notifyDocumentChanged(); return; } catch (error) { firstError = error; } try { await Word.run(async (context) => { context.document.getSelection().insertOoxml(PAGE_BREAK_PARAGRAPH_OOXML, "End"); await context.sync(); }); } catch (fallbackError) { throw new Error( `แทรกตัวแบ่งหน้าไม่สำเร็จทั้งสองวิธี — insertBreak: ${getDetailedErrorMessage(firstError)} · OOXML: ${getDetailedErrorMessage(fallbackError)}`, ); } } autoSaveService.notifyDocumentChanged(); } finally { autoSaveService.resume(); } } export interface ApplyFailure { tag: string; /** สาเหตุที่อ่านได้ (รวม code/errorLocation จาก OfficeExtension.Error ถ้ามี) */ reason: string; } export interface ApplyTestValuesResult { /** จำนวนช่อง (CC) ที่ถูกเขียนค่าจริง — tag เดียวซ้ำหลายช่องนับทุกช่อง * ⚠️ ข้อจำกัด granularity ราย tag: ถ้า tag หนึ่งผูกหลายช่องและ batch sync พังกลางคัน * (ช่องแรกถูกเขียนแล้วแต่ช่องหลังโดนปฏิเสธ) ทั้ง tag จะถูกนับเป็น failures แม้บางช่องเขียนสำเร็จ — * เรียก listContentControls หลังเขียนเพื่อเห็นค่าจริงในเอกสาร */ appliedCount: number; /** tag ที่ส่งค่ามาแต่ไม่มีช่องตรงในเอกสารตอนเขียน (เช่น ถูกลบไปหลังสแกน) */ missingTags: string[]; /** tag ที่ Word ปฏิเสธการเขียน (เช่น เอกสารถูก Restrict Editing หรือช่องถูกล็อค/แก้ไม่ได้) — * เขียนแบบแยกราย tag เพื่อให้เหลืออื่น ๆ ยังสำเร็จและรู้สาเหตุเป็นรายตัว */ failures: ApplyFailure[]; } /** เขียนค่าทดสอบกลับเข้าทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่อง แบบตัวแปรเดียวหลายจุด) * - ค่าที่เว้นว่างจะไม่ถูกแตะ (กันเผลอล้างค่าจริงของช่องนั้น) — ว่างทั้งหมด = ไม่เปิด Word.run * - อ่าน id+tag สดรอบเดียว ไม่เชื่อ snapshot สแกน แล้วเขียน**แยก Word.run ราย tag** — * tag ที่ Word ปฏิเสธ (เอกสารถูก Restrict Editing / ช่องแก้ไม่ได้) fail เฉพาะตัว เหลืออื่นยังเขียนได้ * และเหตุผลถูกเก็บลง failures (กรณีเคยเจอจริง: error 0x800A16C6 บนเอกสารที่ make-protected ล็อค) */ export async function applyTestValues(values: Record): Promise { const entries = Object.entries(values).filter(([, value]) => value.trim() !== ""); if (entries.length === 0) { return { appliedCount: 0, missingTags: [], failures: [] }; } await autoSaveService.suspend(); try { // สำรวจ id ของ CC ราย tag รอบเดียว (เทียบหลัง trim เหมือนฟอร์ม) — id ใช้ได้ทั้ง session นี้ const idsByTag = new Map(); await Word.run(async (context) => { const controls = supportedContentControls(context); controls.load("id, tag"); await context.sync(); for (const control of controls.items) { const tag = (control.tag ?? "").trim(); if (tag === "" || !(tag in values)) continue; const ids = idsByTag.get(tag) ?? []; ids.push(control.id); idsByTag.set(tag, ids); } }); let appliedCount = 0; const missingTags: string[] = []; const failures: ApplyFailure[] = []; for (const [tag, value] of entries) { const ids = idsByTag.get(tag); if (!ids || ids.length === 0) { missingTags.push(tag); continue; } try { let written = 0; await Word.run(async (context) => { const targets = ids.map((id) => supportedContentControls(context).getByIdOrNullObject(id)); targets.forEach((control) => control.load("id")); await context.sync(); const alive = targets.filter((control) => !control.isNullObject); alive.forEach((control) => control.insertText(value, "Replace")); await context.sync(); written = alive.length; }); appliedCount += written; } catch (tagError) { failures.push({ tag, reason: getDetailedErrorMessage(tagError) }); } } if (appliedCount > 0) { autoSaveService.notifyDocumentChanged(); } return { appliedCount, missingTags, failures }; } finally { autoSaveService.resume(); } }