Compare commits

...

3 commits

Author SHA1 Message Date
Your Name
6daee15851 feat(wordaddin): add template tools tab
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
แท็บที่สองของ 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 <noreply@anthropic.com>
2026-10-20 09:49:17 +07:00
Your Name
a172b111a3 docs(wordaddin): sideload dev ชี้ trusted catalog ที่ public/wordaddin
เครื่อง 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 <noreply@anthropic.com>
2026-10-16 14:48:04 +07:00
Your Name
1aeaae38f1 fix(wordaddin): lower WordApi requirement for Office 2019
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 <noreply@anthropic.com>
2026-10-16 14:20:24 +07:00
9 changed files with 844 additions and 54 deletions

View file

@ -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/ ← **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/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/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/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) 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 ใช้ร่วม) 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/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 เท่านั้น) 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/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 ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) 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 ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "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/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "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 หน้า) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)

View file

@ -5,7 +5,7 @@ import { Spinner } from "./Spinner";
/** /**
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10) * 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 เดิม เปลี่ยนแค่ชั้นภาพ * พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
*/ */
export function SaveControls({ export function SaveControls({
@ -13,7 +13,6 @@ export function SaveControls({
busy, busy,
status, status,
error, error,
autoSaveError,
closeSupported, closeSupported,
onSave, onSave,
onSaveAndClose, onSaveAndClose,
@ -23,7 +22,6 @@ export function SaveControls({
busy: boolean; busy: boolean;
status: "saving" | "saved" | null; status: "saving" | "saved" | null;
error: string | null; error: string | null;
autoSaveError: string | null;
closeSupported: boolean; closeSupported: boolean;
onSave: () => void; onSave: () => void;
onSaveAndClose: () => void; onSaveAndClose: () => void;
@ -60,7 +58,6 @@ export function SaveControls({
</p> </p>
) : null} ) : null}
{error ? <MessageBar intent="error">{error}</MessageBar> : null} {error ? <MessageBar intent="error">{error}</MessageBar> : null}
{autoSaveError ? <MessageBar intent="warning">{autoSaveError}</MessageBar> : null}
</div> </div>
); );
} }

View file

@ -19,6 +19,7 @@ import ConfirmVersionDialog from "./ConfirmVersionDialog";
import { MessageBar } from "./MessageBar"; import { MessageBar } from "./MessageBar";
import SaveControls from "./SaveControls"; import SaveControls from "./SaveControls";
import { Spinner } from "./Spinner"; import { Spinner } from "./Spinner";
import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel"; import VersionHistoryPanel from "./VersionHistoryPanel";
/** /**
@ -39,6 +40,18 @@ import VersionHistoryPanel from "./VersionHistoryPanel";
type Phase = "checking" | "signed-out" | "untracked" | "tracking"; 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 = 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"; "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 TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking"); const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
const [activeTab, setActiveTab] = useState<Tab>("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<string | null>(null); const [bootError, setBootError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null); const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
@ -121,6 +139,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
const handleAuthRequired = useCallback((error: unknown): boolean => { const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) { if (error instanceof AuthRequiredError) {
setPhase("signed-out"); setPhase("signed-out");
setActiveTab("versioning");
setDocumentId(null); setDocumentId(null);
setDocumentTemplateId(null); setDocumentTemplateId(null);
setVersions([]); setVersions([]);
@ -241,6 +260,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
setVersions([]); setVersions([]);
setLoginError(null); setLoginError(null);
setPhase("signed-out"); setPhase("signed-out");
setActiveTab("versioning");
}; };
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
@ -403,6 +423,42 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} ) : null}
</div> </div>
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
<MessageBar intent="warning">{autoSaveError}</MessageBar>
) : 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") && (
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
<button
type="button"
aria-pressed={activeTab === "versioning"}
className={tabButtonClass(activeTab === "versioning")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("versioning")}
>
Versioning
</button>
<button
type="button"
aria-pressed={activeTab === "template"}
className={tabButtonClass(activeTab === "template")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("template")}
>
เครื่องมือ Template
</button>
</div>
)}
{phase === "checking" ? ( {phase === "checking" ? (
<div className="flex flex-col items-center gap-4 pt-10"> <div className="flex flex-col items-center gap-4 pt-10">
<Spinner label="กำลังตรวจสอบเอกสาร…" /> <Spinner label="กำลังตรวจสอบเอกสาร…" />
@ -443,7 +499,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div> </div>
) : null} ) : null}
{phase === "untracked" ? ( {phase === "untracked" && activeTab === "versioning" ? (
<div className="flex flex-col gap-2 pt-4"> <div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p> <p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
<p className="text-sm text-ink-soft"> <p className="text-sm text-ink-soft">
@ -470,7 +526,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
busy={saveBusy} busy={saveBusy}
status={saveStatus} status={saveStatus}
error={saveError} error={saveError}
autoSaveError={autoSaveError}
closeSupported={closeSupported} closeSupported={closeSupported}
onSave={() => void handleSaveOnly()} onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()} onSaveAndClose={() => void handleSaveAndClose()}
@ -480,7 +535,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div> </div>
) : null} ) : null}
{phase === "tracking" ? ( {phase === "tracking" && activeTab === "versioning" ? (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */ /* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
<div className="flex flex-col gap-2 pt-4"> <div className="flex flex-col gap-2 pt-4">
{/* กลุ่มข้อความสถานะ */} {/* กลุ่มข้อความสถานะ */}
@ -512,7 +567,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
busy={saveBusy} busy={saveBusy}
status={saveStatus} status={saveStatus}
error={saveError} error={saveError}
autoSaveError={autoSaveError}
closeSupported={closeSupported} closeSupported={closeSupported}
onSave={() => void handleSaveOnly()} onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()} onSaveAndClose={() => void handleSaveAndClose()}
@ -535,43 +589,56 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div> </div>
) : null} ) : null}
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์ {/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */} สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
<ConfirmVersionDialog {(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
open={confirmOpen} <TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
busy={saving} ) : null}
onConfirm={(message) => {
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 */} {/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
<ConfirmVersionDialog overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
open={startConfirmOpen} เปิดค้างขณะสลับแท็บ) */}
busy={starting} {(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
title="เริ่มติดตามเอกสารนี้?" <>
description={ {/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
documentTemplateId + เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" <ConfirmVersionDialog
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" open={confirmOpen}
} busy={saving}
confirmLabel="เริ่มติดตาม (version 1)" onConfirm={(message) => {
onConfirm={(message) => { setConfirmOpen(false);
setStartConfirmOpen(false); void handleSaveAndVersion(message);
void handleStartVersioning(message); }}
}} {...(closeSupported
onDismiss={() => setStartConfirmOpen(false)} ? {
/> onConfirmClose: (message: string) => {
setConfirmOpen(false);
void handleSaveAndVersion(message, true);
},
}
: {})}
onDismiss={() => setConfirmOpen(false)}
/>
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
<ConfirmVersionDialog
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => {
setStartConfirmOpen(false);
void handleStartVersioning(message);
}}
onDismiss={() => setStartConfirmOpen(false)}
/>
</>
) : null}
</div> </div>
); );
}; };

View file

@ -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<ScannedField[] | null>(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<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(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<string>();
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<string, number>();
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<string, string> = {};
const seen = new Set<string>();
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) */
<div className="flex flex-col gap-2 pt-4">
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
{/* §1 ตรวจสอบ Content Controls */}
<div className="mt-2 flex items-baseline justify-between gap-2">
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
</div>
<button
type="button"
className={`${BTN_SECONDARY} w-full justify-center`}
disabled={busy}
aria-busy={scanning}
onClick={() => void runScan(false)}
>
{scanning ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<ScanSearch className="size-4" aria-hidden />
)}
สแกนช่องในเอกสาร
</button>
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
<MessageBar intent="warning">
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
{duplicateTags.length > 0
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
: ""}
</MessageBar>
) : null}
{!fields && !scanning ? (
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
) : null}
{fields && fields.length === 0 && !scanning ? (
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
) : null}
{fields && fields.length > 0 ? (
<ol className="relative">
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
{fields.map((field, index) => (
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-ink-faint">{index + 1}.</span>
<span
className="break-all text-sm font-medium text-ink"
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
>
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
</span>
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
</span>
<span className="ml-auto">
<button
type="button"
className={BTN_ACTION}
disabled={busy}
onClick={() => void handleView(field.id)}
>
<Crosshair className="size-3.5" aria-hidden />
ดูช่อง
</button>
</span>
</div>
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
{field.text.trim() === "" ? "(ว่าง)" : field.text}
</p>
</li>
))}
</ol>
) : null}
{/* §2 กรอกค่าทดสอบ */}
{uniqueTags.length > 0 ? (
<>
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
<p className="text-sm text-ink-soft">
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
</p>
<div className="flex flex-col gap-2" aria-busy={applying}>
{uniqueTags.map((tag, index) => (
<div key={tag} className="flex flex-col gap-1">
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
{tag}
</label>
<input
id={`wordaddin-template-value-${index}`}
className="genesis-input block w-full"
value={values[tag] ?? ""}
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
disabled={busy}
/>
</div>
))}
</div>
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center`}
disabled={busy || !hasFilledValue}
onClick={() => void handleApply()}
>
<PenLine className="size-4" aria-hidden />
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
</button>
</>
) : fields ? (
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
) : (
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
)}
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
<div className="grid grid-cols-3 gap-1">
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingParagraph}
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
onClick={() => void handleInsertParagraph()}
>
{insertingParagraph ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<CornerDownLeft className="size-4" aria-hidden />
)}
ย่อหน้า
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "line"}
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
onClick={() => void handleInsertBreak("line")}
>
{insertingBreak === "line" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<WrapText className="size-4" aria-hidden />
)}
แบ่งบรรทัด
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "page"}
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
onClick={() => void handleInsertBreak("page")}
>
{insertingBreak === "page" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<FilePlus className="size-4" aria-hidden />
)}
แบ่งหน้า
</button>
</div>
</div>
);
}
export default TemplateToolsPanel;

View file

@ -34,13 +34,22 @@ manifest ต้องชี้ https — 2 ทางเลือก:
## Sideload ใน Word Desktop (Windows) ## Sideload ใน Word Desktop (Windows)
1. รัน dev server (https) แล้วดาวน์โหลด manifest: `https://localhost:3000/docedit/wordaddin/manifest.xml` เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
2. เอาไฟล์ manifest ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
3. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์นั้น > restart Word 1. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์ `public/wordaddin` ของ repo (เช่น `D:\dms\judes-note\public\wordaddin`) > restart Word
4. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning" 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 อยู่ดี 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) ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ) - **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ)

View file

@ -10,6 +10,8 @@
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`: 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 checking ──(ไม่มี token)──────────────→ signed-out
│ │
@ -28,6 +30,7 @@ State ที่ UI ถือนอกเหนือจาก phase:
| State | ความหมาย | | State | ความหมาย |
|---|---| |---|---|
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | | `username` | ชื่อผู้ใช้จาก token (แสดงใน header) |
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
@ -104,7 +107,7 @@ Component ใช้ร่วมทั้ง `untracked` และ `tracking` —
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"` - `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout) - `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
- `saveError` → MessageBar **error** - `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` - ทั้งสองปุ่ม `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 3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน 4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด 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-<index>`) ค่าเริ่ม = ค่าปัจจุบันของช่องแรกของ 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 สมบูรณ์ครอบ `<w:br w:type="page"/>` — 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

View file

@ -18,7 +18,7 @@
--> -->
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp"> <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id> <Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
<Version>1.0.0.1</Version> <Version>1.0.0.2</Version>
<ProviderName>e-Court Documents Versioning</ProviderName> <ProviderName>e-Court Documents Versioning</ProviderName>
<DefaultLocale>en-US</DefaultLocale> <DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="e-Court Documents Versioning"/> <DisplayName DefaultValue="e-Court Documents Versioning"/>
@ -33,11 +33,16 @@
<Host Name="Document"/> <Host Name="Document"/>
</Hosts> </Hosts>
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง) <!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
WordApi 1.5: customProperties / customXmlParts / document.save() WordApi 1.3: API สูงสุดที่ใช้นอกเหนือจาก close() — customXmlParts (1.3), customProperties /
document.save() / contentControls (1.1) — และเป็นระดับสูงสุดที่ Office 2019 perpetual
รองรับ (WordApi 1.4/1.5 = Microsoft 365 เท่านั้น — เดิมตั้ง 1.5 ทำให้เครื่องศาลที่ใช้
Office 2019 โหลด add-in ไม่ขึ้นเลย 2026-10-16); document.close() (FR-18) ตรวจ runtime
แยกที่ canCloseDocument() → isSetSupported("WordApi","1.5") — เครื่องที่ไม่ถึงซ่อนปุ่ม
ปิดเอกสารทุกแบบ (พฤติกรรมเดียวกับ Word on the web)
OpenBrowserWindowApi 1.1: เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) --> OpenBrowserWindowApi 1.1: เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
<Requirements> <Requirements>
<Sets DefaultMinVersion="1.1"> <Sets DefaultMinVersion="1.1">
<Set Name="WordApi" MinVersion="1.5"/> <Set Name="WordApi" MinVersion="1.3"/>
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/> <Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
</Sets> </Sets>
</Requirements> </Requirements>

View file

@ -8,3 +8,21 @@ export function getErrorMessage(error: unknown, fallback: string): string {
} }
return fallback; 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;
}

View file

@ -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<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();
}
}