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
208
wordaddin/services/autoSaveService.ts
Normal file
208
wordaddin/services/autoSaveService.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* global console */
|
||||
|
||||
/**
|
||||
* FR-17 — Auto Save: บันทึก local (`document.save`) เท่านั้น ห้ามสร้าง version อัตโนมัติ
|
||||
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
|
||||
*
|
||||
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
|
||||
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
|
||||
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
|
||||
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
|
||||
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
|
||||
*/
|
||||
|
||||
import { isDocumentDirty, saveDocument } from "./documentService";
|
||||
|
||||
export interface AutoSaveConfig {
|
||||
enabled: boolean;
|
||||
debounceMs: number;
|
||||
periodicSaveEnabled: boolean;
|
||||
periodicIntervalMs: number;
|
||||
}
|
||||
|
||||
/** TS constants ตาม spec §8 — แก้ที่นี่ที่เดียว (ไม่มี runtime config) */
|
||||
export const autoSaveConfig: AutoSaveConfig = {
|
||||
enabled: true,
|
||||
debounceMs: 3_000,
|
||||
periodicSaveEnabled: true,
|
||||
periodicIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
class WordAutoSaveService {
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private saving = false;
|
||||
private pendingSave = false;
|
||||
/** depth ของ suspend() — จะรัน timer ใหม่เฉพาะเมื่อ resume ครบทุกชั้น */
|
||||
private suspendDepth = 0;
|
||||
private idleWaiters: Array<() => void> = [];
|
||||
private started = false;
|
||||
private uncommitted = false;
|
||||
|
||||
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
|
||||
onError: ((error: unknown) => void) | undefined;
|
||||
/** save สำเร็จหนึ่งรอบ — App ใช้เคลียร์ banner error เดิม */
|
||||
onSaved: (() => void) | undefined;
|
||||
/** state เปลี่ยน (isSaving / hasUncommittedSinceVersion) — App sync React state */
|
||||
onStateChange: (() => void) | undefined;
|
||||
|
||||
get isSaving(): boolean {
|
||||
return this.saving;
|
||||
}
|
||||
|
||||
/** FR-3 — เคยเห็น dirty หลัง version ล่าสุด (เคลียร์เมื่อสร้าง version สำเร็จ) */
|
||||
get hasUncommittedSinceVersion(): boolean {
|
||||
return this.uncommitted;
|
||||
}
|
||||
|
||||
/** FR-17 §16 — เริ่มตอน Office.onReady ได้เลย ไม่ต้องรอ sign-in */
|
||||
start(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.startTimers();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false;
|
||||
this.stopTimers();
|
||||
}
|
||||
|
||||
/** flow ที่จัดการ save เอง (FR-2/FR-18) — หยุด timer + รอ save ค้างจบ แล้วค่อยทำงานต่อ */
|
||||
async suspend(): Promise<void> {
|
||||
this.suspendDepth++;
|
||||
this.stopTimers();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** คู่ของ suspend() — ซ้อนกี่ชั้นต้อง resume ครบก่อน timer ถึงจะกลับมา */
|
||||
resume(): void {
|
||||
if (this.suspendDepth > 0) {
|
||||
this.suspendDepth--;
|
||||
}
|
||||
if (this.suspendDepth === 0) {
|
||||
this.startTimers();
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
|
||||
notifyDocumentChanged(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.clearDebounce();
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = undefined;
|
||||
this.save().catch((error) => this.reportError(error));
|
||||
}, autoSaveConfig.debounceMs);
|
||||
}
|
||||
|
||||
/** บันทึกเดี๋ยวนี้ (ยกเลิก debounce ค้าง) — ถ้ามี save กำลังทำงานจะ mark pending แทน (coalesce) */
|
||||
async save(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
if (this.saving) {
|
||||
this.pendingSave = true;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.notifyState();
|
||||
try {
|
||||
await saveDocument();
|
||||
if (this.pendingSave) {
|
||||
this.pendingSave = false;
|
||||
await saveDocument();
|
||||
}
|
||||
this.onSaved?.();
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
this.saving = false;
|
||||
this.notifyState();
|
||||
this.resolveIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกให้เสร็จก่อนกลับ — ใช้ก่อน close/upload (spec auto-save ข้อ 12) เรียกหลัง suspend() เสมอ */
|
||||
async forceSave(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
await this.waitIdle();
|
||||
await this.save();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** FR-3 — version ใหม่ถูกสร้างสำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" */
|
||||
clearUncommittedSinceVersion(): void {
|
||||
this.uncommitted = false;
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
private startTimers(): void {
|
||||
if (!this.started || !autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (autoSaveConfig.periodicSaveEnabled && !this.periodicTimer) {
|
||||
this.periodicTimer = setInterval(() => {
|
||||
void this.periodicTick();
|
||||
}, autoSaveConfig.periodicIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private stopTimers(): void {
|
||||
this.clearDebounce();
|
||||
if (this.periodicTimer) {
|
||||
clearInterval(this.periodicTimer);
|
||||
this.periodicTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearDebounce(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async periodicTick(): Promise<void> {
|
||||
if (this.saving || this.suspendDepth > 0) {
|
||||
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
|
||||
}
|
||||
try {
|
||||
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
|
||||
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
|
||||
if (!(await isDocumentDirty())) {
|
||||
return;
|
||||
}
|
||||
this.uncommitted = true;
|
||||
this.notifyState();
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private waitIdle(): Promise<void> {
|
||||
return this.saving
|
||||
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
private resolveIdle(): void {
|
||||
const waiters = this.idleWaiters;
|
||||
this.idleWaiters = [];
|
||||
for (const waiter of waiters) {
|
||||
waiter();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyState(): void {
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
private reportError(error: unknown): void {
|
||||
console.warn("auto-save failed:", error);
|
||||
this.onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const autoSaveService = new WordAutoSaveService();
|
||||
Loading…
Add table
Add a link
Reference in a new issue