feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin: - wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด (authService/versioningService เป็น mock รอ backend จริง) + models + config - components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ) และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system - scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close) - middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8 (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม - docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
7a146d48be
commit
c401f4deb7
37 changed files with 11216 additions and 17 deletions
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
|
||||
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
|
||||
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
|
||||
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
|
||||
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
|
||||
* (pattern overlay เดียวกับ AnnotationSidebar)
|
||||
*/
|
||||
|
||||
const DEFAULT_TITLE = "สร้าง version ใหม่?";
|
||||
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
|
||||
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"focus-ring 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";
|
||||
const BTN_PRIMARY =
|
||||
"focus-ring rounded-md bg-primary px-3 py-1.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";
|
||||
|
||||
export function ConfirmVersionDialog({
|
||||
open,
|
||||
busy,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onConfirmClose,
|
||||
onDismiss,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
|
||||
busy: boolean;
|
||||
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
|
||||
onConfirm: (message: string) => void;
|
||||
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
|
||||
onConfirmClose?: (message: string) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
|
||||
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMessage("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
|
||||
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy) onDismiss();
|
||||
if (event.key === "Tab") {
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
const first = focusables?.item(0);
|
||||
const last = focusables?.item((focusables?.length ?? 0) - 1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, busy, onDismiss]);
|
||||
|
||||
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||
onClick={() => !busy && onDismiss()}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ?? DEFAULT_TITLE}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
|
||||
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
|
||||
คำอธิบาย version (ไม่บังคับ)
|
||||
</label>
|
||||
<textarea
|
||||
id="wordaddin-version-message"
|
||||
className="genesis-input mt-1 block w-full resize-y"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
|
||||
maxLength={2048}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
aria-label="คำอธิบาย version"
|
||||
/>
|
||||
{busy ? (
|
||||
<div className="mt-3">
|
||||
<Spinner label="กำลังดำเนินการ…" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
|
||||
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirm(message)}
|
||||
>
|
||||
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
{onConfirmClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full`}
|
||||
disabled={busy}
|
||||
onClick={() => onConfirmClose(message)}
|
||||
>
|
||||
บันทึกเวอร์ชันและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmVersionDialog;
|
||||
Loading…
Add table
Add a link
Reference in a new issue