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
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>
This commit is contained in:
parent
a172b111a3
commit
6daee15851
7 changed files with 823 additions and 47 deletions
|
|
@ -51,7 +51,7 @@ lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กล
|
|||
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
|
||||
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
|
||||
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank)
|
||||
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank)
|
||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
||||
|
|
@ -83,7 +83,7 @@ app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+
|
|||
app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client)
|
||||
app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น)
|
||||
components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง)
|
||||
components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel
|
||||
components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)**
|
||||
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<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/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Spinner } from "./Spinner";
|
|||
|
||||
/**
|
||||
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||
* banner autoSaveError อยู่ที่นี่เพื่อให้เห็นทุก phase ไม่เฉพาะตอน tracking
|
||||
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
|
||||
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||
*/
|
||||
export function SaveControls({
|
||||
|
|
@ -13,7 +13,6 @@ export function SaveControls({
|
|||
busy,
|
||||
status,
|
||||
error,
|
||||
autoSaveError,
|
||||
closeSupported,
|
||||
onSave,
|
||||
onSaveAndClose,
|
||||
|
|
@ -23,7 +22,6 @@ export function SaveControls({
|
|||
busy: boolean;
|
||||
status: "saving" | "saved" | null;
|
||||
error: string | null;
|
||||
autoSaveError: string | null;
|
||||
closeSupported: boolean;
|
||||
onSave: () => void;
|
||||
onSaveAndClose: () => void;
|
||||
|
|
@ -60,7 +58,6 @@ export function SaveControls({
|
|||
</p>
|
||||
) : null}
|
||||
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||
{autoSaveError ? <MessageBar intent="warning">{autoSaveError}</MessageBar> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
|||
import { MessageBar } from "./MessageBar";
|
||||
import SaveControls from "./SaveControls";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TemplateToolsPanel from "./TemplateToolsPanel";
|
||||
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||
|
||||
/**
|
||||
|
|
@ -39,6 +40,18 @@ import VersionHistoryPanel from "./VersionHistoryPanel";
|
|||
|
||||
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
|
||||
|
||||
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
|
||||
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
|
||||
type Tab = "versioning" | "template";
|
||||
|
||||
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
|
||||
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
|
||||
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
|
||||
const tabButtonClass = (active: boolean) =>
|
||||
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
|
||||
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
|
||||
}`;
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
|
|
@ -80,6 +93,11 @@ function demoVersions(now: number): VersionRecord[] {
|
|||
|
||||
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
|
||||
const [phase, setPhase] = useState<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 [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 => {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
setPhase("signed-out");
|
||||
setActiveTab("versioning");
|
||||
setDocumentId(null);
|
||||
setDocumentTemplateId(null);
|
||||
setVersions([]);
|
||||
|
|
@ -241,6 +260,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
setVersions([]);
|
||||
setLoginError(null);
|
||||
setPhase("signed-out");
|
||||
setActiveTab("versioning");
|
||||
};
|
||||
|
||||
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
|
||||
|
|
@ -403,6 +423,42 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
) : null}
|
||||
</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" ? (
|
||||
<div className="flex flex-col items-center gap-4 pt-10">
|
||||
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||
|
|
@ -443,7 +499,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "untracked" ? (
|
||||
{phase === "untracked" && activeTab === "versioning" ? (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
|
|
@ -470,7 +526,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
autoSaveError={autoSaveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
|
|
@ -480,7 +535,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "tracking" ? (
|
||||
{phase === "tracking" && activeTab === "versioning" ? (
|
||||
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{/* กลุ่มข้อความสถานะ */}
|
||||
|
|
@ -512,7 +567,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
autoSaveError={autoSaveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
|
|
@ -535,43 +589,56 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
|
||||
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
|
||||
<ConfirmVersionDialog
|
||||
open={confirmOpen}
|
||||
busy={saving}
|
||||
onConfirm={(message) => {
|
||||
setConfirmOpen(false);
|
||||
void handleSaveAndVersion(message);
|
||||
}}
|
||||
{...(closeSupported
|
||||
? {
|
||||
onConfirmClose: (message: string) => {
|
||||
setConfirmOpen(false);
|
||||
void handleSaveAndVersion(message, true);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
onDismiss={() => setConfirmOpen(false)}
|
||||
/>
|
||||
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
|
||||
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
|
||||
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
|
||||
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
|
||||
) : null}
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
{/* 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) */}
|
||||
<ConfirmVersionDialog
|
||||
open={confirmOpen}
|
||||
busy={saving}
|
||||
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 */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
411
components/wordaddin/TemplateToolsPanel.tsx
Normal file
411
components/wordaddin/TemplateToolsPanel.tsx
Normal 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;
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
|
||||
|
||||
ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
|
||||
|
||||
```
|
||||
checking ──(ไม่มี token)──────────────→ signed-out
|
||||
│
|
||||
|
|
@ -28,6 +30,7 @@ State ที่ UI ถือนอกเหนือจาก phase:
|
|||
|
||||
| State | ความหมาย |
|
||||
|---|---|
|
||||
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
|
||||
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) |
|
||||
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
|
||||
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
|
||||
|
|
@ -104,7 +107,7 @@ Component ใช้ร่วมทั้ง `untracked` และ `tracking` —
|
|||
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
|
||||
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
|
||||
- `saveError` → MessageBar **error**
|
||||
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง")
|
||||
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง") — **ย้ายไป render เหนือ toggle แท็บใน TaskpaneApp (2026-10-20)** ให้เห็นทั้งแท็บ Versioning และ Template ไม่มาใน SaveControls แล้ว
|
||||
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
|
||||
|
||||
พฤติกรรมปุ่ม:
|
||||
|
|
@ -272,3 +275,43 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน
|
|||
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
|
||||
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
|
||||
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
|
||||
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
|
||||
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
|
||||
|
||||
---
|
||||
|
||||
## 10. แท็บ "เครื่องมือ Template" (2026-10-19)
|
||||
|
||||
เครื่องมือช่วยทำ template สำหรับคนออกแบบเอกสาร — `TemplateToolsPanel` (state ภายในทั้งหมด มีแค่ prop `onBusyChange` รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บ) + service `wordaddin/services/templateService.ts` — **Office.js ล้วน ไม่ยิง backend** (ส่วนใหญ่ WordApi 1.1 · `getByIdOrNullObject` 1.3 · `Document.getContentControls()` 1.5 ผ่าน feature-detect — ดู §9 ข้อ 7) ใช้ได้ทั้ง phase `untracked`/`tracking` · ไม่ auto-scan ตอนเปิดแท็บ (กดปุ่มเท่านั้น) · สลับแท็บแล้ว panel unmount = **ทิ้ง state ทิ้งโดยตั้งใจ** (snapshot เก่าเก็บไว้ก็ stale เสียเอง) · ปุ่มทั้ง panel disable ด้วย `busy` กันกดซ้อน
|
||||
|
||||
### §1 ตรวจสอบ Content Controls
|
||||
|
||||
- ปุ่ม `สแกนช่องในเอกสาร` (icon ScanSearch → spinner + `aria-busy` ตอนรัน) → list timeline แบบ VersionHistoryPanel: ลำดับ · tag (`break-all` + `title` แสดง tag/title/type ดิบ · ช่องไม่มี tag แสดง "(ไม่มี tag)" และจุดโหนดเป็นวงกลมขอบเหลือง) · badge ชนิด "ในบรรทัด"/"บล็อก" (heuristics: enum type ลงท้าย "Inline") · ค่าย่อ truncate 1 บรรทัด + `title` เต็ม · ปุ่ม `ดูช่อง` (icon Crosshair) → `cc.select()` ให้ Word เลื่อนไปเลือกช่อง
|
||||
- สรุปหัวกลุ่ม: "N ช่อง"
|
||||
- warning ใต้ปุ่มเมื่อพบปัญหา: "มี N ช่องที่ยังไม่ได้ตั้ง tag" / "tag ซ้ำ: a, b — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag"
|
||||
- empty 2 แบบ: ยังไม่สแกน ("กดสแกนเพื่อดูรายการช่องในเอกสาร") กับสแกนแล้วว่าง ("ไม่พบ content control ในเอกสาร")
|
||||
|
||||
### §2 กรอกค่าทดสอบ
|
||||
|
||||
- แสดงเฉพาะมีช่องที่ตั้ง tag ≥1 ช่อง (จากผลสแกน): ฟอร์ม label+input ต่อ tag (id `wordaddin-template-value-<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
|
||||
|
|
|
|||
|
|
@ -8,3 +8,21 @@ export function getErrorMessage(error: unknown, fallback: string): string {
|
|||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** เหมือน getErrorMessage แต่ต่อท้าย code/errorLocation จาก debugInfo ของ OfficeExtension.Error —
|
||||
* สำหรับ error จาก Word.run ที่ต้องการสาเหตุละเอียด (เช่น ราย tag ที่เขียนค่าไม่สำเร็จ) */
|
||||
export function getDetailedErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
|
||||
): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
const debugInfo = (error as { debugInfo?: { code?: string; errorLocation?: string } }).debugInfo;
|
||||
const code = debugInfo?.code ? ` [${debugInfo.code}]` : "";
|
||||
const location = debugInfo?.errorLocation ? ` (${debugInfo.errorLocation})` : "";
|
||||
return `${error.message}${code}${location}`;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
|
|
|||
240
wordaddin/services/templateService.ts
Normal file
240
wordaddin/services/templateService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue