241 lines
15 KiB
TypeScript
241 lines
15 KiB
TypeScript
|
|
/* 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<ScannedField[]> {
|
|||
|
|
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<void> {
|
|||
|
|
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<void> {
|
|||
|
|
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 ที่มี <w:br w:type="page"/>
|
|||
|
|
* ใช้เป็นวิธีสำรองเมื่อ insertBreak("Page") โดน E_FAIL (0x80004005) ในบางบริบท เช่น cursor
|
|||
|
|
* อยู่ในตาราง (เคสจริงบน T3.docx 2026-10-19 — template ของศาลมีตารางเป็นโครงหลัก) · ระดับ fragment
|
|||
|
|
* ต้องเป็น block-level เต็มตัว ไม่ใช่ run เดี่ยว (ลองแล้วยัง E_FAIL อีกรอบ) */
|
|||
|
|
const PAGE_BREAK_PARAGRAPH_OOXML =
|
|||
|
|
'<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:r><w:br w:type="page"/></w:r></w:p>';
|
|||
|
|
|
|||
|
|
/** แทรกตัวแบ่งที่ตำแหน่ง cursor — "line" = ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter) สำหรับ
|
|||
|
|
* จัดที่อยู่/รายการให้ตรงแนว, "page" = ขึ้นหน้าใหม่ (Ctrl+Enter) สำหรับเอกสารหลายหน้า (คำขอผู้ใช้ 2026-10-19) */
|
|||
|
|
export async function insertBreakAtSelection(breakKind: "line" | "page"): Promise<void> {
|
|||
|
|
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<string, string>): Promise<ApplyTestValuesResult> {
|
|||
|
|
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<string, number[]>();
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
}
|