From 1aeaae38f145f17c7eb6cd80221295296020aca5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 16 Oct 2026 14:20:24 +0700 Subject: [PATCH 1/3] fix(wordaddin): lower WordApi requirement for Office 2019 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-in โหลดไม่ขึ้นเลยบนเครื่องศาลที่ใช้ Office 2019 — manifest ตั้ง WordApi 1.5 ซึ่งเป็น Microsoft 365 เท่านั้น (Office 2019 perpetual รองรับสูงสุด 1.3) host ที่ไม่ถึง requirement จะปฏิเสธ โหลด add-in ตั้งแต่ต้นตามกลไกของ Office แก้: รองลงเป็น WordApi 1.3 (customXmlParts คือ API สูงสุดที่ใช้ ประจำ — customProperties/save/contentControls เป็น 1.1) + bump Version 1.0.0.1 → 1.0.0.2 ให้ Word ทุกเครื่องเห็นว่าเป็น เวอร์ชันใหม่แล้วดึง manifest ใหม่ (กันแค manifest เดิมของตัว poc ที่เคย sideload — GUID เดิม) ผลข้างเคียงบน Office 2019: ปุ่ม "บันทึกและปิดเอกสาร" / "บันทึกเวอร์ชันและปิดเอกสาร" ถูกซ่อนอัตโนมัติ — document.close() ต้องการ WordApi 1.4/1.5 โดย canCloseDocument() เช็ค isSetSupported("WordApi","1.5") ตอน runtime อยู่แล้ว (กลไกเดิม ที่ทำไว้สำหรับ Word on the web) ฟีเจอร์อื่นใช้ได้ครบ ยืนยัน: parse XML ผ่าน (@xmldom) — WordApi=1.3, OpenBrowserWindowApi=1.1 (Office 2019 รองรับ), Version=1.0.0.2 · สำรวจ API ที่ใช้จริงทั้ง wordaddin/services แล้วทุกตัว ≤1.3 ยกเว้น close() ที่มี runtime guard Co-Authored-By: Claude Code --- docs/WORDADDIN-DEV.md | 2 ++ public/wordaddin/manifest.template.xml | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index eb8efbc..65edac4 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -41,6 +41,8 @@ manifest ต้องชี้ https — 2 ทางเลือก: Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี +รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16) + ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) - **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ) diff --git a/public/wordaddin/manifest.template.xml b/public/wordaddin/manifest.template.xml index 6cc87ef..efe2e93 100644 --- a/public/wordaddin/manifest.template.xml +++ b/public/wordaddin/manifest.template.xml @@ -18,7 +18,7 @@ --> 4a1e184a-ab31-4fa4-b3c7-9a2283051c3f - 1.0.0.1 + 1.0.0.2 e-Court Documents Versioning en-US @@ -33,11 +33,16 @@ - + From a172b111a36ef378a30b88d97e06730e1bd081a2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 16 Oct 2026 14:48:04 +0700 Subject: [PATCH 2/3] =?UTF-8?q?docs(wordaddin):=20sideload=20dev=20?= =?UTF-8?q?=E0=B8=8A=E0=B8=B5=E0=B9=89=20trusted=20catalog=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=20public/wordaddin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit เครื่อง dev ที่มี repo ไม่ต้องดาวน์โหลด/คัดลอก manifest แยกแล้ว — ชี้ Trusted Add-in Catalogs ไปที่ public/wordaddin ตรง ๆ: manifest.template.xml คือ dev manifest อยู่แล้ว (URL localhost) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout เสมอ ห้ามวาง xml อื่นเพิ่มในโฟลเดอร์นี้ — Word สแกน *.xml ทุกไฟล์ GUID เดียวกันสองไฟล์ทำให้ Word สับสน เครื่องที่ไม่มี repo (production/ผู้ใช้) ยังใช้ flow ดาวน์โหลด จาก URL ของ environment นั้นแล้ววางโฟลเดอร์แยกตามเดิม Co-Authored-By: Claude Code --- docs/WORDADDIN-DEV.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 65edac4..23af799 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -34,10 +34,17 @@ manifest ต้องชี้ https — 2 ทางเลือก: ## Sideload ใน Word Desktop (Windows) -1. รัน dev server (https) แล้วดาวน์โหลด manifest: `https://localhost:3000/docedit/wordaddin/manifest.xml` -2. เอาไฟล์ manifest ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\` -3. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์นั้น > restart Word -4. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning" +เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย): + +1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word +2. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning" +3. ชื่อไฟล์ไม่สำคัญ — Word สแกน `*.xml` ทุกไฟล์ในโฟลเดอร์ โดย `manifest.template.xml` คือ dev manifest อยู่แล้ว (URL localhost:3000) และเนื้อไฟล์อัปเดตตาม branch ที่ checkout อยู่เสมอ — **ห้ามวาง xml manifest อื่นเพิ่มในโฟลเดอร์นี้** (GUID เดียวกันสองไฟล์ = Word สับสน) + +เครื่องที่ไม่มี repo (production/เครื่องผู้ใช้): + +1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml` +2. เอาไฟล์ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\` +3. ทำขั้น Trust Center ข้างบนด้วยโฟลเดอร์นั้น > restart Word > **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning" Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี From 6daee1585199721ba931b2bf4fe55277cb7c304a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 20 Oct 2026 09:49:17 +0700 Subject: [PATCH 3/3] feat(wordaddin): add template tools tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit แท็บที่สองของ taskpane "เครื่องมือ Template" (TemplateToolsPanel + templateService — Office.js ล้วน ไม่ยิง backend ใช้ได้ทั้ง phase untracked/tracking) สำหรับคนออกแบบ template: - สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + ปุ่มดูช่อง (select เลื่อน Word ไปให้เอง) - กรอกค่าทดสอบเขียนกลับเอกสารจริงเพื่อดูผลจัดหน้า — ช่องว่างไม่ถูกแตะ, เขียนแยก Word.run ราย tag ให้ fail เฉพาะตัวพร้อมสาเหตุจาก debugInfo - แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor — page break มี fallback OOXML block-level กัน E_FAIL ในบางบริบท (เช่น cursor ในตาราง) - มองเห็น CC ทุกชนิดผ่าน feature-detect WordApi 1.5 (Document.getContentControls) — Office 2019 เห็นเฉพาะ rich text พร้อม warning แยก state ไม่โดน banner อื่นล้าง, ห้ามยก MinVersion ของ manifest จาก code review 2026-10-20: - ปุ่ม toggle แท็บปิดระหว่าง flow ที่เขียนเอกสารครบทุกตัว (saving/starting/saveBusy/templateBusy — panel รายงาน busy ผ่าน onBusyChange พร้อม cleanup ตอน unmount) กัน flow versioning มา stamp/capture ซ้อน operation ของแท็บ template ที่ยังรันอยู่ - banner auto-save error ยกจาก SaveControls มาไว้เหนือ toggle ให้เห็นทั้งสองแท็บ - สแกน merge หลังเขียนค่าตัด tag ที่หายไปจากเอกสารทิ้ง (ไม่ค้าง ไปโดน missingTags รอบถัดไป) - รวม describeWordError เป็น getDetailedErrorMessage ที่ errors.ts Co-Authored-By: Claude Code --- CLAUDE.md | 4 +- components/wordaddin/SaveControls.tsx | 5 +- components/wordaddin/TaskpaneApp.tsx | 147 +++++-- components/wordaddin/TemplateToolsPanel.tsx | 411 ++++++++++++++++++++ docs/addin-ui-flow.md | 45 ++- wordaddin/services/errors.ts | 18 + wordaddin/services/templateService.ts | 240 ++++++++++++ 7 files changed, 823 insertions(+), 47 deletions(-) create mode 100644 components/wordaddin/TemplateToolsPanel.tsx create mode 100644 wordaddin/services/templateService.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8023344..04d0e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กล wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -83,7 +83,7 @@ app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+ app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) -components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel +components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) diff --git a/components/wordaddin/SaveControls.tsx b/components/wordaddin/SaveControls.tsx index e391d7a..0b930a6 100644 --- a/components/wordaddin/SaveControls.tsx +++ b/components/wordaddin/SaveControls.tsx @@ -5,7 +5,7 @@ import { Spinner } from "./Spinner"; /** * FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10) - * banner autoSaveError อยู่ที่นี่เพื่อให้เห็นทุก phase ไม่เฉพาะตอน tracking + * banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ * พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ */ export function SaveControls({ @@ -13,7 +13,6 @@ export function SaveControls({ busy, status, error, - autoSaveError, closeSupported, onSave, onSaveAndClose, @@ -23,7 +22,6 @@ export function SaveControls({ busy: boolean; status: "saving" | "saved" | null; error: string | null; - autoSaveError: string | null; closeSupported: boolean; onSave: () => void; onSaveAndClose: () => void; @@ -60,7 +58,6 @@ export function SaveControls({

) : null} {error ? {error} : null} - {autoSaveError ? {autoSaveError} : null}
); } diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index a12ec64..5191a79 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -19,6 +19,7 @@ import ConfirmVersionDialog from "./ConfirmVersionDialog"; import { MessageBar } from "./MessageBar"; import SaveControls from "./SaveControls"; import { Spinner } from "./Spinner"; +import TemplateToolsPanel from "./TemplateToolsPanel"; import VersionHistoryPanel from "./VersionHistoryPanel"; /** @@ -39,6 +40,18 @@ import VersionHistoryPanel from "./VersionHistoryPanel"; type Phase = "checking" | "signed-out" | "untracked" | "tracking"; +/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls + * (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */ +type Tab = "versioning" | "template"; + +/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist" + * เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ) + * active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */ +const tabButtonClass = (active: boolean) => + `focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${ + active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary" + }`; + 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"; @@ -80,6 +93,11 @@ function demoVersions(now: number): VersionRecord[] { const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => { const [phase, setPhase] = useState(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking"); + const [activeTab, setActiveTab] = useState("versioning"); + /** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) — + * ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */ + const [templateBusy, setTemplateBusy] = useState(false); + const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []); const [bootError, setBootError] = useState(null); const [username, setUsername] = useState(demo ? (authService.getUsername() ?? null) : null); @@ -121,6 +139,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => const handleAuthRequired = useCallback((error: unknown): boolean => { if (error instanceof AuthRequiredError) { setPhase("signed-out"); + setActiveTab("versioning"); setDocumentId(null); setDocumentTemplateId(null); setVersions([]); @@ -241,6 +260,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => setVersions([]); setLoginError(null); setPhase("signed-out"); + setActiveTab("versioning"); }; /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ @@ -403,6 +423,42 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => ) : null} + {/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ + versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้ + จึงต้องเห็น error แม้อยู่อีกแท็บ */} + {(phase === "untracked" || phase === "tracking") && autoSaveError ? ( + {autoSaveError} + ) : null} + + {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ) + ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย + กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก + local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template + แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise + ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */} + {(phase === "untracked" || phase === "tracking") && ( +
+ + +
+ )} + {phase === "checking" ? (
@@ -443,7 +499,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {phase === "untracked" ? ( + {phase === "untracked" && activeTab === "versioning" ? (

เอกสารนี้ยังไม่ได้ติดตาม version

@@ -470,7 +526,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => busy={saveBusy} status={saveStatus} error={saveError} - autoSaveError={autoSaveError} closeSupported={closeSupported} onSave={() => void handleSaveOnly()} onSaveAndClose={() => void handleSaveAndClose()} @@ -480,7 +535,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>

) : null} - {phase === "tracking" ? ( + {phase === "tracking" && activeTab === "versioning" ? ( /* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
{/* กลุ่มข้อความสถานะ */} @@ -512,7 +567,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => busy={saveBusy} status={saveStatus} error={saveError} - autoSaveError={autoSaveError} closeSupported={closeSupported} onSave={() => void handleSaveOnly()} onSaveAndClose={() => void handleSaveAndClose()} @@ -535,43 +589,56 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์ - + เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */} - { - setConfirmOpen(false); - void handleSaveAndVersion(message); - }} - {...(closeSupported - ? { - onConfirmClose: (message: string) => { - setConfirmOpen(false); - void handleSaveAndVersion(message, true); - }, - } - : {})} - onDismiss={() => setConfirmOpen(false)} - /> + {/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร + สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */} + {(phase === "untracked" || phase === "tracking") && activeTab === "template" ? ( + + ) : null} - {/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */} - { - setStartConfirmOpen(false); - void handleStartVersioning(message); - }} - onDismiss={() => setStartConfirmOpen(false)} - /> + {/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด + overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog + เปิดค้างขณะสลับแท็บ) */} + {(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? ( + <> + {/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์ + + เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */} + { + setConfirmOpen(false); + void handleSaveAndVersion(message); + }} + {...(closeSupported + ? { + onConfirmClose: (message: string) => { + setConfirmOpen(false); + void handleSaveAndVersion(message, true); + }, + } + : {})} + onDismiss={() => setConfirmOpen(false)} + /> + + {/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */} + { + setStartConfirmOpen(false); + void handleStartVersioning(message); + }} + onDismiss={() => setStartConfirmOpen(false)} + /> + + ) : null} ); }; diff --git a/components/wordaddin/TemplateToolsPanel.tsx b/components/wordaddin/TemplateToolsPanel.tsx new file mode 100644 index 0000000..3267edf --- /dev/null +++ b/components/wordaddin/TemplateToolsPanel.tsx @@ -0,0 +1,411 @@ +"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; diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index df079ad..83ef6da 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -10,6 +10,8 @@ Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`: +ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ + ``` checking ──(ไม่มี token)──────────────→ signed-out │ @@ -28,6 +30,7 @@ State ที่ UI ถือนอกเหนือจาก phase: | State | ความหมาย | |---|---| +| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | | `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | @@ -104,7 +107,7 @@ Component ใช้ร่วมทั้ง `untracked` และ `tracking` — - `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"` - `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout) - `saveError` → MessageBar **error** - - `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") + - `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว - ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy` พฤติกรรมปุ่ม: @@ -272,3 +275,43 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน 3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text 4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน 5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด +6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง +7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16) + +--- + +## 10. แท็บ "เครื่องมือ Template" (2026-10-19) + +เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน + +### §1 ตรวจสอบ Content Controls + +- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง +- สรุปหัวกลุ่ม: "N ช่อง" +- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag" +- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร") + +### §2 กรอกค่าทดสอบ + +- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ tag นั้น + ปุ่ม primary `เขียนค่าลงเอกสาร` (icon PenLine → "กำลังเขียนค่า…" ตอนรัน) +- **ช่องที่เว้นว่าง = ไม่ถูกแตะ** (กันเผลอล้างค่าจริง) · เขียนทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่องแบบ mail merge) · อ่าน id+tag สดรอบเดียวแล้วเขียน**แยก Word.run ราย tag** — tag ที่ Word ปฏิเสธ fail เฉพาะตัว เหลืออื่นยังเขียนได้ (ผลลัพธ์: `appliedCount` = จำนวนช่องที่เขียนจริง + `missingTags` ไม่พบช่อง + `failures` พร้อม code/errorLocation จาก OfficeExtension.Error) · UI รวมเป็น warning พร้อมชี้นำ "ถ้าเอกสารถูกล็อค (Review → Restrict Editing) ให้ปลดก่อน" — เคสจริงที่เจอ: error `0x800A16C6` บนเอกสารที่ `make-protected.mjs` ล็อคไว้ (readOnly + enforcement) +- ผลลัพธ์: success "เขียนค่าทดสอบลงแล้ว N ช่อง" (N = จำนวนช่องที่เขียนจริง — tag ซ้ำหลายช่องนับทุกช่อง) + warning missingTags ถ้ามี → **สแกนใหม่โดยคงค่าฟอร์ม** (เฉพาะ tag ที่ยังอยู่ในเอกสาร — tag ที่ถูกลบไปตัดทิ้ง) ให้แก้ต่อ · ปุ่ม disabled เมื่อยังไม่มีช่องที่กรอกค่าเลย · หลังเขียนสำเร็จ `notifyDocumentChanged()` ตั้ง debounce auto-save 3 วิ ส่วน banner FR-3 ตั้งเมื่อ periodic tick ถัดไปของ autoSaveService เห็น document dirty (พฤติกรรมเดิมของ service — ไม่ใช่ทันที) + +### §3 แทรกย่อหน้า / ตัวแบ่ง (2026-10-19) + +- 3 ปุ่มแนวตั้ง (icon บน label ล่าง, grid 3 คอลัมน์, `title` อธิบายเต็ม): **ย่อหน้า** (CornerDownLeft — `insertParagraph("", "After")` เหมือนกด Enter) · **แบ่งบรรทัด** (WrapText — `insertBreak("Line", "After")` เหมือน Shift+Enter จัดที่อยู่/รายการในย่อหน้าเดิม) · **แบ่งหน้า** (FilePlus — เหมือน Ctrl+Enter เอกสารหลายหน้า: `insertBreak("Page", "After")` ก่อน ถ้า host ปฏิเสธ E_FAIL 0x80004005 (บางบริบท เช่น cursor ในตาราง — เคสจริงบน T3 2026-10-19) สลับไป `insertOoxml` แบบ w:p สมบูรณ์ครอบ `` — fragment ต้องเป็น block-level เต็มตัว ใช้ run เดี่ยวก็ยัง E_FAIL) +- กดแล้วทำที่ cursor ทันที (ไม่มี success message — เห็นผลในเอกสารเลย) · icon สลับเป็น spinner ตอนรันตัวนั้น · ทุกปุ่ม disable ด้วย `busy` ร่วมกับเครื่องมืออื่น · fallback error: "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" + +### หมายเหตุ: ไม่มีปุ่มสลับ formatting marks (และไม่มีชุดแทรกอักขระพิเศษ) + +- **Office.js ไม่มี API สลับแสดง/ซ่อน formatting marks** (¶ ท้ายย่อหน้า/จุดช่องว่าง) — เป็น application setting ของ Word ที่ WordApi ไม่เปิดให้ add-in แตะ ผู้ใช้กด **Ctrl+Shift+8** หรือปุ่ม **¶ บนแท็บ Home** ของ Word เอง (workflow จริงของผู้ใช้: แทรกย่อหน้าเพิ่ม → เปิด marks ตรวจ → ซ่อนกลับ — จึงมีปุ่มแทรกย่อหน้าให้ ส่วน marks ใช้ของ Word) +- เคยมีชุด "แทรกอักขระพิเศษ" (เลขไทย ๐–๙ + สัญลักษณ์) ต่อท้าย panel แล้ว**ถอดออก 2026-10-19** ตามคำขอผู้ใช้ — ที่ต้องการจริงคือ formatting marks ซึ่งทำผ่าน add-in ไม่ได้ (อ่านบันทึกเต็มใน git history รอบนั้น) +- เคยมีเครื่องมือ **"สร้างช่องจากข้อความที่เลือก"** (dialog ตั้ง tag + `insertContentControl` ครอบ selection + `validateTag` + `TagInputDialog` + unit test ของ validateTag) แล้ว**ถอดออกทั้งเส้น 2026-10-19** ตามคำขอผู้ใช้ — การสร้างช่องใหม่ทำใน Word ด้วย Developer tab แทน + +### ข้อความ error กลางของ panel (MessageBar intent error) + +- สแกนพัง: fallback "สแกนช่องในเอกสารไม่สำเร็จ" · ดูช่องพัง: "ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่" / fallback "เลือกช่องไม่สำเร็จ" · เขียนค่าพัง: fallback "เขียนค่าลงเอกสารไม่สำเร็จ" · แทรกพัง: fallback "แทรกย่อหน้าไม่สำเร็จ" / "แทรกตัวแบ่งบรรทัดไม่สำเร็จ" / "แทรกตัวแบ่งหน้าไม่สำเร็จ" (แบ่งหน้าที่พังทั้งสองวิธีจะรวมสาเหตุของทั้ง insertBreak และ OOXML fallback ในข้อความเดียว) + +### พฤติกรรมใน browser ธรรมดา / `?addindemo` + +ทุกปุ่มที่แตะเอกสาร (`สแกน`/`ดูช่อง`/`เขียนค่า`/`ย่อหน้า`/`แบ่งบรรทัด`/`แบ่งหน้า`) จบที่ error MessageBar ("Word is not defined") — ตามปรัชญา demo ของ repo เหมือนปุ่ม Save & Version ส่วน toggle/สลับแท็บ/ฟอร์มค่าทดสอบ ทดสอบได้ครบใน browser diff --git a/wordaddin/services/errors.ts b/wordaddin/services/errors.ts index 7c78432..1744ebd 100644 --- a/wordaddin/services/errors.ts +++ b/wordaddin/services/errors.ts @@ -8,3 +8,21 @@ export function getErrorMessage(error: unknown, fallback: string): string { } return fallback; } + +/** เหมือน getErrorMessage แต่ต่อท้าย code/errorLocation จาก debugInfo ของ OfficeExtension.Error — + * สำหรับ error จาก Word.run ที่ต้องการสาเหตุละเอียด (เช่น ราย tag ที่เขียนค่าไม่สำเร็จ) */ +export function getDetailedErrorMessage( + error: unknown, + fallback = "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ", +): string { + if (error instanceof Error && error.message) { + const debugInfo = (error as { debugInfo?: { code?: string; errorLocation?: string } }).debugInfo; + const code = debugInfo?.code ? ` [${debugInfo.code}]` : ""; + const location = debugInfo?.errorLocation ? ` (${debugInfo.errorLocation})` : ""; + return `${error.message}${code}${location}`; + } + if (typeof error === "string" && error) { + return error; + } + return fallback; +} diff --git a/wordaddin/services/templateService.ts b/wordaddin/services/templateService.ts new file mode 100644 index 0000000..85c5c99 --- /dev/null +++ b/wordaddin/services/templateService.ts @@ -0,0 +1,240 @@ +/* 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(); + } +}