diff --git a/CLAUDE.md b/CLAUDE.md index 04d0e60..8023344 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 พอร์ตดิบทั้งไฟล์ + **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) +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) 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 · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** +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/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 0b930a6..e391d7a 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 ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ + * banner autoSaveError อยู่ที่นี่เพื่อให้เห็นทุก phase ไม่เฉพาะตอน tracking * พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ */ export function SaveControls({ @@ -13,6 +13,7 @@ export function SaveControls({ busy, status, error, + autoSaveError, closeSupported, onSave, onSaveAndClose, @@ -22,6 +23,7 @@ export function SaveControls({ busy: boolean; status: "saving" | "saved" | null; error: string | null; + autoSaveError: string | null; closeSupported: boolean; onSave: () => void; onSaveAndClose: () => void; @@ -58,6 +60,7 @@ export function SaveControls({

) : null} {error ? {error} : null} + {autoSaveError ? {autoSaveError} : null}
); } diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index 5191a79..a12ec64 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -19,7 +19,6 @@ import ConfirmVersionDialog from "./ConfirmVersionDialog"; import { MessageBar } from "./MessageBar"; import SaveControls from "./SaveControls"; import { Spinner } from "./Spinner"; -import TemplateToolsPanel from "./TemplateToolsPanel"; import VersionHistoryPanel from "./VersionHistoryPanel"; /** @@ -40,18 +39,6 @@ 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"; @@ -93,11 +80,6 @@ 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); @@ -139,7 +121,6 @@ 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([]); @@ -260,7 +241,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => setVersions([]); setLoginError(null); setPhase("signed-out"); - setActiveTab("versioning"); }; /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ @@ -423,42 +403,6 @@ 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" ? (
@@ -499,7 +443,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {phase === "untracked" && activeTab === "versioning" ? ( + {phase === "untracked" ? (

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

@@ -526,6 +470,7 @@ 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,7 +480,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>

) : null} - {phase === "tracking" && activeTab === "versioning" ? ( + {phase === "tracking" ? ( /* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
{/* กลุ่มข้อความสถานะ */} @@ -567,6 +512,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => busy={saveBusy} status={saveStatus} error={saveError} + autoSaveError={autoSaveError} closeSupported={closeSupported} onSave={() => void handleSaveOnly()} onSaveAndClose={() => void handleSaveAndClose()} @@ -589,56 +535,43 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร - สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */} - {(phase === "untracked" || phase === "tracking") && activeTab === "template" ? ( - - ) : null} - - {/* 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 */} - { + setConfirmOpen(false); + void handleSaveAndVersion(message); + }} + {...(closeSupported + ? { + onConfirmClose: (message: string) => { + setConfirmOpen(false); + void handleSaveAndVersion(message, true); + }, } - confirmLabel="เริ่มติดตาม (version 1)" - onConfirm={(message) => { - setStartConfirmOpen(false); - void handleStartVersioning(message); - }} - onDismiss={() => setStartConfirmOpen(false)} - /> - - ) : null} + : {})} + onDismiss={() => setConfirmOpen(false)} + /> + + {/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */} + { + setStartConfirmOpen(false); + void handleStartVersioning(message); + }} + onDismiss={() => setStartConfirmOpen(false)} + /> ); }; diff --git a/components/wordaddin/TemplateToolsPanel.tsx b/components/wordaddin/TemplateToolsPanel.tsx deleted file mode 100644 index 3267edf..0000000 --- a/components/wordaddin/TemplateToolsPanel.tsx +++ /dev/null @@ -1,411 +0,0 @@ -"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/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 23af799..eb8efbc 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -34,22 +34,13 @@ manifest ต้องชี้ https — 2 ทางเลือก: ## Sideload ใน Word Desktop (Windows) -เครื่อง 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" +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" 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/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 83ef6da..df079ad 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -10,8 +10,6 @@ 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 │ @@ -30,7 +28,6 @@ State ที่ UI ถือนอกเหนือจาก phase: | State | ความหมาย | |---|---| -| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | | `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | @@ -107,7 +104,7 @@ Component ใช้ร่วมทั้ง `untracked` และ `tracking` — - `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"` - `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout) - `saveError` → MessageBar **error** - - `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว + - `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") - ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy` พฤติกรรมปุ่ม: @@ -275,43 +272,3 @@ 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/public/wordaddin/manifest.template.xml b/public/wordaddin/manifest.template.xml index efe2e93..6cc87ef 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.2 + 1.0.0.1 e-Court Documents Versioning en-US @@ -33,16 +33,11 @@ - + diff --git a/wordaddin/services/errors.ts b/wordaddin/services/errors.ts index 1744ebd..7c78432 100644 --- a/wordaddin/services/errors.ts +++ b/wordaddin/services/errors.ts @@ -8,21 +8,3 @@ 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 deleted file mode 100644 index 85c5c99..0000000 --- a/wordaddin/services/templateService.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* 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(); - } -}