Compare commits
44 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677de2fc29 | ||
|
|
ce646ffc20 | ||
|
|
b8ff622999 | ||
|
|
37b18c58c2 | ||
|
|
4200176239 | ||
|
|
7cd8939789 | ||
|
|
cffc571da5 | ||
|
|
69d83ea493 | ||
|
|
8ebe5f8644 | ||
|
|
50af6c10fa | ||
|
|
a3d14fd58d | ||
|
|
6ce071ee14 | ||
|
|
ab62a2b3e2 | ||
|
|
c06da83f92 | ||
|
|
86353cbe57 | ||
|
|
0b2c9e6343 | ||
|
|
aad6f155fe | ||
|
|
98b370c925 | ||
|
|
ac6c0dc71e | ||
|
|
280cc2da40 | ||
|
|
949861ba86 | ||
|
|
ed8493004c | ||
|
|
765946f0d5 | ||
|
|
6daee15851 | ||
|
|
5917c0532c | ||
|
|
a172b111a3 | ||
|
|
1aeaae38f1 | ||
|
|
4bcd01a95f | ||
|
|
808233191d | ||
|
|
2b70a50c62 | ||
|
|
dc851fbb73 | ||
|
|
33670d0b18 | ||
|
|
c401f4deb7 | ||
|
|
7a146d48be | ||
|
|
b41816e2b6 | ||
|
|
073f72db7d | ||
|
|
8cc15ea75d | ||
|
|
b123ecc9b2 | ||
|
|
c08aaab1ed | ||
|
|
e4ecc026b0 | ||
|
|
0681e056fd | ||
|
|
8571ef9aaf | ||
|
|
651e354cad | ||
|
|
45e60330b8 |
157 changed files with 32129 additions and 1003 deletions
|
|
@ -11,7 +11,8 @@ paths:
|
|||
- ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path
|
||||
- `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100)
|
||||
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
|
||||
- `rectsInSweptPath(rects, start, end)` (กรอง selection rects ตามเส้นทางลาก): บรรทัดแรก = จุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้าย = ต้นบรรทัด→จุดจบ, **บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง** — ช่วงคอลัมน์ยึดจากขอบซ้าย/ขวาจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย (ไม่ใช่พิกัดจุดลากตรง ๆ — ลากชัน/ลาก margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มตาม native) + ขยายริมละ 2×lineTol ให้ทน justify gap ระหว่าง rect (pad อิงความสูงบรรทัด ตาม zoom) — span ประกอบที่ห่างเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ถูกตัดทิ้ง (bug report 2026-10-05 + code review ข้อ 1)
|
||||
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
|
||||
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
|
||||
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ
|
||||
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)**
|
||||
- AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ paths:
|
|||
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
|
||||
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
|
||||
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
|
||||
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
|
||||
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/note/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
|
||||
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/note/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/note")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
|
||||
- API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
|
||||
- API ภายนอก (N10 — `app/api/note/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,7 @@ paths:
|
|||
|
||||
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
|
||||
|
||||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
|
||||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
|
||||
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
|
||||
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case
|
||||
- Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + see
|
|||
## ขั้นตอน
|
||||
|
||||
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
|
||||
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
||||
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
||||
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
|
||||
|
||||
## ข้อควรระวัง
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ npm-debug.log*
|
|||
.env.*
|
||||
!.env.example
|
||||
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
|
||||
public/uploads
|
||||
public/note/uploads
|
||||
|
|
|
|||
49
.env.example
49
.env.example
|
|
@ -5,3 +5,52 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
|||
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
|
||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
|
||||
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||
DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit
|
||||
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
|
||||
EXTERNAL_API_KEY='secret-api-key-1234'
|
||||
# ที่เก็บไฟล์เทมเพลต(docx)
|
||||
TMPL_PATH='./public/tmpl'
|
||||
|
||||
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
|
||||
SECRET="super_secret"
|
||||
PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"
|
||||
PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546"
|
||||
# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data)
|
||||
TICKET_SEC=300
|
||||
|
||||
|
||||
|
||||
# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
|
||||
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
|
||||
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
|
||||
# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ —
|
||||
# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# MINIO_BUCKET=docver
|
||||
# DOCVER_LINK_TTL_SECONDS=900
|
||||
|
||||
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
|
||||
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
|
||||
DOCVER_API_KEY=
|
||||
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
|
||||
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
|
||||
DOCVER_TOKEN_SECRET=
|
||||
# DOCVER_TOKEN_TTL_SECONDS=3600
|
||||
|
||||
# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token
|
||||
# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp
|
||||
# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง
|
||||
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
|
||||
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
|
||||
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
|
||||
# ADDIN_TICKET_SECRET=super_secret
|
||||
# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง
|
||||
# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน)
|
||||
# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -29,7 +29,16 @@ next-env.d.ts
|
|||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||
public/uploads/
|
||||
public/note/uploads/
|
||||
|
||||
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
|
||||
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
|
||||
public/wordaddin/office-js/
|
||||
|
||||
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
|
||||
/certs/
|
||||
|
||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||
docker-compose.test.yml
|
||||
|
||||
certificates
|
||||
88
CLAUDE.md
88
CLAUDE.md
File diff suppressed because one or more lines are too long
77
README.md
77
README.md
|
|
@ -19,7 +19,7 @@
|
|||
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
|
||||
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` |
|
||||
|
||||
## ขั้นตอน Setup จากศูนย์
|
||||
|
||||
|
|
@ -31,25 +31,30 @@ npm install
|
|||
|
||||
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
|
||||
|
||||
### 2. รัน PostgreSQL container
|
||||
### 2. รัน PostgreSQL + MinIO ด้วย docker compose
|
||||
|
||||
```bash
|
||||
docker run -d --name judes-note-db \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
|
||||
-p 54322:5432 \
|
||||
-v judes-note-db-data:/var/lib/postgresql/data \
|
||||
postgres:17-alpine
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
|
||||
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio`
|
||||
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
|
||||
|
||||
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
|
||||
|
||||
### 3. สร้างไฟล์ `.env`
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||
# N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2)
|
||||
MINIO_ENDPOINT=127.0.0.1
|
||||
MINIO_PORT=19000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
```
|
||||
|
||||
(เทียบกับ `.env.example`)
|
||||
|
|
@ -62,13 +67,13 @@ npx drizzle-kit push
|
|||
|
||||
### 5. Seed ไฟล์ตัวอย่าง
|
||||
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/note/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||
|
||||
```bash
|
||||
npm run seed-samples
|
||||
```
|
||||
|
||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/note/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||
|
||||
### 6. รัน dev server
|
||||
|
||||
|
|
@ -76,7 +81,7 @@ npm run seed-samples
|
|||
npm run dev
|
||||
```
|
||||
|
||||
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
|
||||
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`)
|
||||
|
||||
## การใช้งานบนหน้า annotate
|
||||
|
||||
|
|
@ -94,10 +99,10 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
|
|||
|
||||
## API สำหรับระบบภายนอก (N10)
|
||||
|
||||
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
|
||||
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/note/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
|
||||
|
||||
```jsonc
|
||||
// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
||||
// POST /docedit/api/note/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
||||
// headers: x-api-key + content-type: application/json
|
||||
{
|
||||
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
|
||||
|
|
@ -118,13 +123,35 @@ Read-only API ให้ **backend ของ e-Court** เรียกแบบ s
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"typeId":"CASE-2569-0001"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
## API จัด version เอกสาร (docver — stand-alone)
|
||||
|
||||
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
|
||||
|
||||
```bash
|
||||
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
|
||||
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
|
||||
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
|
||||
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
|
||||
|
||||
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
|
||||
|
||||
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
|
||||
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
|
||||
```
|
||||
|
||||
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
|
||||
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
|
||||
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| อาการ | สาเหตุ/ทางแก้ |
|
||||
|
|
@ -132,12 +159,13 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
|
||||
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
|
||||
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
|
||||
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
|
||||
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
|
||||
|
||||
## หมายเหตุทางเทคนิค
|
||||
|
||||
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
|
||||
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/note/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
|
||||
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
|
||||
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
|
||||
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
|
||||
|
|
@ -148,15 +176,18 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
||||
db/index.ts postgres.js + drizzle (prepare: false)
|
||||
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
|
||||
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
|
||||
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
|
||||
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
||||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link)
|
||||
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||
components/sidebar/ AnnotationSidebar
|
||||
public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||
public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||
public/note/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
|
||||
|
||||
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
|
||||
|
||||
|
|
@ -141,6 +142,20 @@ function vPoint(v: unknown, field: string): { x: number; y: number } {
|
|||
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
|
||||
}
|
||||
|
||||
/** vPoint แบบไม่บังคับ ≥ 0 — ใช้กับ STAMP ที่หมุน: position เป็นมุมกรอบ*ก่อนหมุน*
|
||||
* ซึ่งติดลบ/เกิน 100 ได้โดยกรอบจริง (AABB ของกรอบหมุน) ยังอยู่ในหน้าครบ — เกณฑ์
|
||||
* ความถูกต้องของพิกัดอยู่ที่ AABB ซึ่งผู้เรียกตรวจเอง (code review 2026-09-28) */
|
||||
function vPointFinite(v: unknown, field: string): { x: number; y: number } {
|
||||
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
|
||||
const p = v as Record<string, unknown>;
|
||||
for (const axis of ["x", "y"] as const) {
|
||||
if (typeof p[axis] !== "number" || !Number.isFinite(p[axis])) {
|
||||
fieldError(`${field}.${axis}`, "เป็นตัวเลข");
|
||||
}
|
||||
}
|
||||
return { x: p.x as number, y: p.y as number };
|
||||
}
|
||||
|
||||
function vRect(v: unknown, field: string): Rect {
|
||||
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
|
||||
const r = v as Record<string, unknown>;
|
||||
|
|
@ -338,7 +353,9 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
}
|
||||
case "STAMP": {
|
||||
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
|
||||
const position = vPoint(d.position, "data.position");
|
||||
// position = มุมกรอบก่อนหมุน — ติดลบได้เมื่อตรายางเอียง (ดู vPointFinite)
|
||||
// เกณฑ์ "ไม่ติดลบ" ตรวจที่ AABB ของตรายางด้านล่างแทน position ตรง ๆ
|
||||
const position = vPointFinite(d.position, "data.position");
|
||||
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
|
||||
if (
|
||||
typeof d.scale !== "number" ||
|
||||
|
|
@ -350,12 +367,41 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
|
||||
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
|
||||
const note = vNote(d.note);
|
||||
// มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ) optional — ไม่มี = ไม่เอียง
|
||||
if (
|
||||
d.rotation !== undefined &&
|
||||
(typeof d.rotation !== "number" ||
|
||||
!Number.isFinite(d.rotation) ||
|
||||
d.rotation < -180 ||
|
||||
d.rotation > 180)
|
||||
) {
|
||||
fieldError("data.rotation", "เป็นตัวเลข −180 ถึง 180");
|
||||
}
|
||||
// เกณฑ์พิกัดไม่ติดลบของ STAMP = AABB ของกรอบ (หมุนรอบกลางกรอบแล้ว): ตรายาง
|
||||
// เอียงชิดขอบ มุมก่อนหมุนหลุดนอกหน้าได้โดยตัวตรายางจริงยังอยู่ในหน้าครบ —
|
||||
// ตรวจ position ตรง ๆ แล้ว client clamp (ยึด AABB) ขัดกับ validator จนลาก
|
||||
// ถึงขอบแล้วเด้งกลับ (code review 2026-09-28); ไม่มี rotation → AABB = rect
|
||||
// เดิม เกณฑ์เดิมเป๊ะ — คำนวณด้วย estimate (pure ฝั่ง server ไม่มี DOM)
|
||||
const stampAabb = rotatedRectAABB(
|
||||
stampRect(
|
||||
{ label, position, scale: d.scale, rotation: d.rotation ?? 0 },
|
||||
{ width: 100, height: 100 },
|
||||
),
|
||||
);
|
||||
// tolerance 2% ของหน้า: clamp ฝั่ง client ยึด measure จริงของ browser
|
||||
// (measureStampText) ขณะที่นี่ยึด stampLayoutEstimate — ฟอนต์ต่อเครื่องทำให้
|
||||
// จำนวนบรรทัด/AABB คลาดกันนิดหน่อย ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธเด้งกลับ
|
||||
// (code review 2026-09-29) — ยอมให้ AABB ยื่นเกินขอบนิดหน่อยแลกกับไม่เด้ง
|
||||
if (stampAabb.x < -2 || stampAabb.y < -2) {
|
||||
fieldError("data.position", "เป็นพิกัดที่ตรายางยังอยู่ในหน้า (ไม่ติดลบ)");
|
||||
}
|
||||
return {
|
||||
label,
|
||||
position,
|
||||
scale: d.scale,
|
||||
...(color ? { color } : {}),
|
||||
...(note !== undefined ? { note } : {}),
|
||||
...(d.rotation !== undefined ? { rotation: d.rotation } : {}),
|
||||
} satisfies StampData;
|
||||
}
|
||||
case "SHAPE": {
|
||||
|
|
@ -399,8 +445,8 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
||||
*/
|
||||
function revalidateFilePages(fileId: string) {
|
||||
revalidatePath(`/annotate/${fileId}`);
|
||||
revalidatePath(`/annotate/${fileId}/summary`);
|
||||
revalidatePath(`/note/annotate/${fileId}`);
|
||||
revalidatePath(`/note/annotate/${fileId}/summary`);
|
||||
}
|
||||
|
||||
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,6 @@ export async function logout(): Promise<void> {
|
|||
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
||||
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/login");
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้ารายการที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/note/login");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use server";
|
||||
|
||||
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
||||
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||
// เขียนไฟล์**ใหม่**ที่ public/note/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
|
@ -13,9 +13,10 @@ import { db } from "@/db";
|
|||
import { files } from "@/db/schema";
|
||||
import { normDocumentGroup } from "@/utils/external-file";
|
||||
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
||||
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "note", "uploads");
|
||||
|
||||
export type UploadFileResult =
|
||||
| { ok: true; fileId: string; title: string }
|
||||
|
|
@ -67,7 +68,10 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
|||
if (file.size === 0) {
|
||||
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
||||
}
|
||||
const ext = allowedExt(file.name);
|
||||
// ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) —
|
||||
// ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title
|
||||
const fileName = decodeUploadFilename(file.name);
|
||||
const ext = allowedExt(fileName);
|
||||
if (!ext) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -93,7 +97,7 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
|||
const diskPath = path.join(UPLOAD_DIR, diskName);
|
||||
await writeFile(diskPath, bytes);
|
||||
|
||||
const title = sanitizeTitle(file.name);
|
||||
const title = sanitizeTitle(fileName);
|
||||
const pdfUrl = `/uploads/${diskName}`;
|
||||
const typeId = normDocumentGroup(formData.get("typeId"));
|
||||
try {
|
||||
|
|
@ -104,6 +108,6 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
|||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/note");
|
||||
return { ok: true, fileId: id, title };
|
||||
}
|
||||
|
|
|
|||
97
app/api/documents/token/route.ts
Normal file
97
app/api/documents/token/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
|
||||
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
|
||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
||||
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
||||
// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
|
||||
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
|
||||
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in)
|
||||
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
||||
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
|
||||
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
||||
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
||||
import { docverError } from "@/lib/document-versioning";
|
||||
import {
|
||||
addinTicketSecret,
|
||||
parseTicketTokenBody,
|
||||
ticketToDocverClaims,
|
||||
verifyAddinTicket,
|
||||
} from "@/lib/addin-ticket";
|
||||
import {
|
||||
issueDocverToken,
|
||||
parseTokenRequestBody,
|
||||
requireDocverApiKey,
|
||||
type DocverTokenClaims,
|
||||
} from "@/lib/docver-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */
|
||||
const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return request.headers.get("x-api-key") !== null
|
||||
? postWithApiKey(request)
|
||||
: postWithTicket(request);
|
||||
}
|
||||
|
||||
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
|
||||
async function postWithApiKey(request: Request): Promise<Response> {
|
||||
const apiKeyFail = requireDocverApiKey(request);
|
||||
if (apiKeyFail !== null) return apiKeyFail;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTokenRequestBody(raw);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
return issueToken(parsed.claims);
|
||||
}
|
||||
|
||||
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
|
||||
async function postWithTicket(request: Request): Promise<Response> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseTicketTokenBody(raw);
|
||||
if (!parsed.ok) {
|
||||
// missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน)
|
||||
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
|
||||
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
|
||||
}
|
||||
const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
|
||||
if (!verified.ok) return docverError(TICKET_DENIED, 401);
|
||||
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||
}
|
||||
|
||||
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
|
||||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
// const secret =
|
||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||
const ttlSeconds =
|
||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
||||
|
||||
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: ttlSeconds,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
|
||||
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
||||
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
|
||||
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
|
||||
import {
|
||||
docverError,
|
||||
parseDownloadFormat,
|
||||
parseVersionIdParam,
|
||||
} from "@/lib/document-versioning";
|
||||
import { requireDocverBearer } from "@/lib/docver-token";
|
||||
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { versionId: string } },
|
||||
) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||
const docId = auth.claims.fid;
|
||||
const versionId = parseVersionIdParam(params.versionId);
|
||||
if (!versionId.ok) return docverError(versionId.error, 400);
|
||||
let format: string | null = null;
|
||||
try {
|
||||
format = new URL(request.url).searchParams.get("format");
|
||||
} catch {
|
||||
return docverError("URL ของ request ไม่ถูกต้อง", 400);
|
||||
}
|
||||
const parsedFormat = parseDownloadFormat(format);
|
||||
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
|
||||
|
||||
try {
|
||||
const row = await findVersionForDoc(docId, versionId.versionId);
|
||||
if (!row) {
|
||||
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||||
}
|
||||
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
|
||||
return docverError("version นี้ไม่มีสำเนา PDF", 404);
|
||||
}
|
||||
const link = await presignVersionUrl(row.id, parsedFormat.format);
|
||||
return new Response(JSON.stringify(link), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: สร้าง download link ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
170
app/api/documents/versions/route.ts
Normal file
170
app/api/documents/versions/route.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions
|
||||
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
|
||||
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
|
||||
// Authorization: Bearer ทุก request — identity (userid/username), templateId และ
|
||||
// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด
|
||||
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { toDocumentVersionDTO } from "@/db/schema";
|
||||
import {
|
||||
docverError,
|
||||
DOCVER_MAX_UPLOAD_BYTES,
|
||||
DOCX_CONTENT_TYPE,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseVersionUploadForm,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
import { requireDocverBearer } from "@/lib/docver-token";
|
||||
import {
|
||||
findDocumentByDocId,
|
||||
insertVersionRecord,
|
||||
listVersionsByDocId,
|
||||
putVersionObjects,
|
||||
removeVersionObjects,
|
||||
versionObjectKeys,
|
||||
} from "@/lib/docver-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||
export async function POST(request: Request) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
|
||||
const docId = auth.claims.fid;
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น multipart/form-data", 400);
|
||||
}
|
||||
|
||||
const parsed = parseVersionUploadForm(form);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
|
||||
}
|
||||
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
|
||||
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
|
||||
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
|
||||
}
|
||||
|
||||
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
|
||||
const rawPdf = form.get("pdf");
|
||||
let pdf: File | null = null;
|
||||
if (rawPdf instanceof File && rawPdf.size > 0) {
|
||||
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
|
||||
}
|
||||
pdf = rawPdf;
|
||||
}
|
||||
|
||||
let docxBytes: Buffer;
|
||||
let pdfBytes: Buffer | null = null;
|
||||
try {
|
||||
docxBytes = Buffer.from(await file.arrayBuffer());
|
||||
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
|
||||
} catch (err) {
|
||||
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
if (!isDocxBytes(docxBytes)) {
|
||||
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
|
||||
}
|
||||
if (pdfBytes && !isPdfBytes(pdfBytes)) {
|
||||
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
|
||||
const versionId = randomUUID();
|
||||
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
|
||||
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
|
||||
const docxDisplayName =
|
||||
parsed.form.fileName ?? sanitizeFilePartName(file.name);
|
||||
|
||||
try {
|
||||
await putVersionObjects({
|
||||
versionId,
|
||||
docx: { bytes: docxBytes, fileName: docxDisplayName },
|
||||
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
const keys = versionObjectKeys(versionId);
|
||||
let row;
|
||||
try {
|
||||
row = await insertVersionRecord({
|
||||
versionId,
|
||||
docId,
|
||||
clientVersionId: parsed.form.clientVersionId,
|
||||
contentHash,
|
||||
message: parsed.form.message,
|
||||
fields: parsed.form.fields,
|
||||
templateId: auth.claims.template,
|
||||
fileName: docxDisplayName,
|
||||
docxObjectKey: keys.docx,
|
||||
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
||||
docxSize: docxBytes.length,
|
||||
pdfSize: pdfBytes?.length ?? null,
|
||||
userId: auth.claims.userid,
|
||||
userName: auth.claims.username,
|
||||
});
|
||||
} catch (err) {
|
||||
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
||||
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
|
||||
await removeVersionObjects(versionId, pdfBytes !== null);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hasPdf: pdfBytes !== null,
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
||||
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireDocverBearer(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const docId = auth.claims.fid;
|
||||
|
||||
try {
|
||||
const doc = await findDocumentByDocId(docId);
|
||||
if (!doc) {
|
||||
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
|
||||
}
|
||||
const rows = await listVersionsByDocId(doc.id);
|
||||
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("docver: โหลด version history ล้ม", err);
|
||||
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
PDFName,
|
||||
PDFString,
|
||||
LineCapStyle,
|
||||
degrees,
|
||||
rgb,
|
||||
type PDFFont,
|
||||
type PDFDict,
|
||||
|
|
@ -51,15 +52,16 @@ import { toPx, type Point, type Size } from "@/utils/coordinates";
|
|||
import { fileKindFromUrl } from "@/utils/file-kind";
|
||||
import { imageDocFromBytes } from "@/utils/image-to-pdf";
|
||||
import {
|
||||
STAMP_HEIGHT_RATIO,
|
||||
annotationBBox,
|
||||
rotatePoint,
|
||||
stampBoxHeight,
|
||||
stampLayout,
|
||||
textboxMaxWidthPct,
|
||||
} from "@/utils/geometry";
|
||||
import {
|
||||
STAMP_COLOR,
|
||||
annotationNote,
|
||||
effectiveDrawOpacity,
|
||||
stampFitFontSize,
|
||||
} from "@/utils/annotation-display";
|
||||
|
||||
// Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
|
||||
|
|
@ -596,42 +598,65 @@ export async function GET(req: Request) {
|
|||
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
|
||||
const stampRgb = rgb(r, g, b);
|
||||
const w = px(d.scale, display.width);
|
||||
// จัดวางข้อความแบบเดียวกับบนจอ (stampLayout): คำสั้น 1 บรรทัด, คำยาวพอดี ๆ
|
||||
// ย่อฟอนต์ให้บรรทัดเดียวก่อน (จนถึง floor 10%), ยาวเกินค่อย wrap หลายบรรทัด
|
||||
// กรอบสูงขึ้นตาม — measure ของฝั่งนี้คือ pdf-lib font (2026-09-28)
|
||||
const lay = thaiFont
|
||||
? stampLayout(d.label, w, (s, size) => thaiFont.widthOfTextAtSize(s, size))
|
||||
: null;
|
||||
const rect = displayRectToPdf(
|
||||
{
|
||||
x: px(d.position.x, display.width),
|
||||
y: px(d.position.y, display.height),
|
||||
width: w,
|
||||
height: w * STAMP_HEIGHT_RATIO,
|
||||
height: lay ? stampBoxHeight(lay, w) : w * 0.4,
|
||||
},
|
||||
size,
|
||||
rotation,
|
||||
);
|
||||
// มุมเอียงของตรายาง: จอหมุนรอบ**กลางกรอบ** ส่วน pdf-lib หมุนรอบ anchor มุม
|
||||
// ล่างซ้ายของ object — จึงต้อง (1) ใช้มุมลบ (แกน y ของ PDF ชี้ขึ้น ส่วน CSS
|
||||
// rotate วัดบนจอที่ y ชี้ลง) และ (2) เลื่อน anchor ไปตำแหน่งมุมล่างซ้าย*หลัง
|
||||
// หมุน* ให้ pdf-lib หมุนต่อรอบมัน ผลลัพธ์กลับมาตรงตำแหน่งเดียวกับบนจอ
|
||||
const rot = d.rotation ?? 0;
|
||||
const pdfRot = -rot;
|
||||
const ctr = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
const anchor = rot
|
||||
? rotatePoint({ x: rect.x, y: rect.y }, ctr, pdfRot)
|
||||
: { x: rect.x, y: rect.y };
|
||||
const rotateOpt = rot ? { rotate: degrees(pdfRot) } : undefined;
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
x: anchor.x,
|
||||
y: anchor.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderColor: stampRgb,
|
||||
borderWidth: Math.max(w * 0.02, 1),
|
||||
...rotateOpt,
|
||||
});
|
||||
if (thaiFont) {
|
||||
if (lay && thaiFont) {
|
||||
const font = thaiFont;
|
||||
// ย่อฟอนต์ให้ label อยู่บรรทัดเดียวพอดีกรอบ — เหมือนที่ render บนจอ
|
||||
// (ไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดแล้วล้นกรอบ)
|
||||
const fontSize = stampFitFontSize(
|
||||
d.label,
|
||||
w,
|
||||
(s, size) => font.widthOfTextAtSize(s, size),
|
||||
);
|
||||
const textW = font.widthOfTextAtSize(d.label, fontSize);
|
||||
const lineH = fontSize * 1.2; // กึ่งกลางแนวตั้งตาม lineHeight บนจอ
|
||||
page.drawText(d.label, {
|
||||
x: rect.x + (rect.width - textW) / 2,
|
||||
y: rect.y + (rect.height - lineH) / 2 + lineH - fontSize,
|
||||
size: fontSize,
|
||||
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (pdf y ชี้ขึ้นจากล่าง) — จุดวาด
|
||||
// คำนวณในพิกัดกรอบ*ก่อนหมุน* แล้วหมุนรอบกลางกรอบเหมือนจอก่อนส่ง drawText
|
||||
const lineStep = lay.fontSize * lay.lineHeight;
|
||||
const totalH = lay.lines.length * lineStep;
|
||||
let baseline = rect.y + rect.height / 2 + totalH / 2 - lay.fontSize;
|
||||
for (const ln of lay.lines) {
|
||||
const textW = font.widthOfTextAtSize(ln, lay.fontSize);
|
||||
const tx = rect.x + (rect.width - textW) / 2;
|
||||
const p = rot
|
||||
? rotatePoint({ x: tx, y: baseline }, ctr, pdfRot)
|
||||
: { x: tx, y: baseline };
|
||||
page.drawText(ln, {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
size: lay.fontSize,
|
||||
font,
|
||||
color: stampRgb,
|
||||
...rotateOpt,
|
||||
});
|
||||
baseline -= lineStep;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -642,8 +667,15 @@ export async function GET(req: Request) {
|
|||
if (a.type !== "COMMENT") {
|
||||
const note = annotationNote(a);
|
||||
if (note) {
|
||||
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF
|
||||
const bbox = annotationBBox(a, display);
|
||||
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF — ส่ง
|
||||
// measure ของ pdf-lib font เดียวกับตอนวาดกรอบ STAMP ด้วย ให้มุมเกาะ sticky
|
||||
// note ตรงกรอบที่วาดจริง (ไม่ส่ง = stampLayoutEstimate คลาดจากกรอบจริงเล็กน้อย
|
||||
// — code review 2026-09-29)
|
||||
const bbox = annotationBBox(
|
||||
a,
|
||||
display,
|
||||
thaiFont ? (s, size) => thaiFont.widthOfTextAtSize(s, size) : undefined,
|
||||
);
|
||||
attachNoteSticky(
|
||||
doc,
|
||||
page,
|
||||
|
|
@ -4,8 +4,9 @@ import { db } from "@/db";
|
|||
import { files } from "@/db/schema";
|
||||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||
import { requestOrigin } from "@/utils/request-origin";
|
||||
|
||||
// N10 (PLAN-API.md D2) — POST /api/external/documents
|
||||
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
|
||||
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
||||
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
|
||||
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
|
||||
|
|
@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
|||
|
||||
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||
|
||||
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
|
||||
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
|
||||
* x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย
|
||||
* fallback จาก host/protocol ของ request ตรง ๆ */
|
||||
function requestOrigin(req: Request): string {
|
||||
const url = new URL(req.url);
|
||||
const proto =
|
||||
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
|
||||
url.protocol.replace(":", "");
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("host") ??
|
||||
url.host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม
|
||||
// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16)
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const unauthorized = requireExternalApiKey(req);
|
||||
44
app/api/swagger/route.ts
Normal file
44
app/api/swagger/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { openApiSpec } from "@/lib/openapi";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// GET /api/swagger — หน้า Swagger UI ของ API ทั้งหมด (กลุ่ม template + N10)
|
||||
// (asset สำหรับ intranet: postinstall scripts/copy-swagger-ui.mjs คัดลอก
|
||||
// swagger-ui-dist ไป public/note/swagger-ui/ — pattern เดียวกับ pdf.worker)
|
||||
// spec inline จาก lib/openapi.ts ตรง ๆ ไม่มี endpoint spec แยก
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// กัน </script> ใน JSON ตัดสคริปต์ขาย (spec เป็นของเราเองทั้งหมด แค่กันกรณี
|
||||
// ข้อความ example มี < ในอนาคต)
|
||||
const SPEC_JSON = JSON.stringify(openApiSpec).replace(/</g, "\\u003c");
|
||||
|
||||
const HTML = `<!doctype html>
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Swagger UI — Judge's Note API</title>
|
||||
<link rel="stylesheet" href="${withBasePath("/swagger-ui/swagger-ui.css")}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="${withBasePath("/swagger-ui/swagger-ui-bundle.js")}"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: ${SPEC_JSON},
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export async function GET() {
|
||||
return new NextResponse(HTML, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
109
app/api/template/download-docx/route.ts
Normal file
109
app/api/template/download-docx/route.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
|
||||
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??"";
|
||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??"";
|
||||
const TMPL_PATH = process.env.TMPL_PATH??"";
|
||||
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
|
||||
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
|
||||
}
|
||||
|
||||
// console.log("download-docx env",DOCEDIT_BASE_URL,EXTERNAL_API_KEY,TMPL_PATH)
|
||||
|
||||
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
|
||||
{
|
||||
// console.log("processRequest get:",fid,tid,uid,uname)
|
||||
// console.log(process.cwd())
|
||||
const filePath = path.join(TMPL_PATH,`${fid}.json`);
|
||||
try {
|
||||
if (!fid||!tid||!uid||!uname) {
|
||||
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
|
||||
}
|
||||
|
||||
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
|
||||
const rawJson = await fs.readFile(filePath, 'utf-8');
|
||||
const jsonData = JSON.parse(rawJson);
|
||||
if (!jsonData || typeof jsonData !== 'object' ) {
|
||||
return NextResponse.json({error:'Invalid data format'}, { status: 400 });
|
||||
}
|
||||
|
||||
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!!
|
||||
|
||||
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
|
||||
if(!Object.hasOwn(jsonData, 'info'))
|
||||
jsonData['info']={}
|
||||
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname})
|
||||
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
|
||||
|
||||
//ยิงไปหาระบบ Docedit เพื่อเอา Stream
|
||||
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
|
||||
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': EXTERNAL_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(jsonData),
|
||||
});
|
||||
|
||||
// เช็ค Content-Type และ Status ก่อน
|
||||
const contentType = docxResponse.headers.get('content-type') || '';
|
||||
|
||||
if (!docxResponse.ok || contentType.includes('text/html')) {
|
||||
const errorText = await docxResponse.text();
|
||||
console.error('download-docx:Invalid docx Response:', errorText);
|
||||
|
||||
return NextResponse.json(
|
||||
{error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
|
||||
const stream = docxResponse.body;
|
||||
|
||||
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
|
||||
const download_file = `${fid}_${uid}_xxx`
|
||||
return new NextResponse(stream as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
});
|
||||
} catch (error:any) {
|
||||
if ( error.code === 'ENOENT') {
|
||||
return NextResponse.json(
|
||||
{ error: `Data not found: ${filePath}`},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error(error)
|
||||
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest)
|
||||
{
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const fid = searchParams.get('fid') || '';
|
||||
const tid = searchParams.get('tid') || '';
|
||||
const uid = searchParams.get('uid') || '';
|
||||
const uname = searchParams.get('uname') || '';
|
||||
return await processRequest(fid,tid,uid,uname)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const fid = String(formData.get('fid'))
|
||||
const tid = String(formData.get('tid'))
|
||||
const uid = String(formData.get('uid'))
|
||||
const uname = String(formData.get('uname'))
|
||||
return await processRequest(fid,tid,uid,uname)
|
||||
}
|
||||
82
app/api/template/extract/route.ts
Normal file
82
app/api/template/extract/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
|
||||
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
|
||||
// export const runtime = "nodejs";
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const TEST_SECRET = String(formData.get("secret") ?? "");
|
||||
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
|
||||
|
||||
if (!(file instanceof File) ||
|
||||
(file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Field "file" must be a .docx file.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Field "file" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// File → Buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// ใช้ lib-tmpl.ts เดิม
|
||||
// const data = extractContentControls(buffer);
|
||||
|
||||
const cc = extractContentControls(buffer);
|
||||
const bookmark = extractBookmark(buffer)
|
||||
const properties = getDocProps(buffer)
|
||||
let verify: Record<string, unknown> ={}
|
||||
const ticket_data = properties?.custom?.ticket_data
|
||||
const HmacSha256 = String(properties?.custom?.HmacSha256)
|
||||
const Ed25519 = String(properties?.custom?.Ed25519)
|
||||
|
||||
if(ticket_data && typeof ticket_data === 'string'){
|
||||
const ticketDataObj= JSON.parse(ticket_data)
|
||||
const unix_timestamp = Number(ticketDataObj.exp)*1000
|
||||
if(unix_timestamp){
|
||||
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
|
||||
}
|
||||
if(TEST_SECRET && HmacSha256){
|
||||
verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
|
||||
}
|
||||
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
|
||||
verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
|
||||
}
|
||||
}
|
||||
|
||||
let returnData : Record<string, unknown> = {cc,bookmark,properties,verify}
|
||||
return NextResponse.json(returnData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Internal Server Error",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/api/template/generate/route.ts
Normal file
104
app/api/template/generate/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// สร้าง docx จาก json
|
||||
// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้
|
||||
// - สร้าง signature/hash จาก json.info.ticket_data
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {readFile} from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
|
||||
const TMPL_PATH = process.env.TMPL_PATH || ''
|
||||
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
|
||||
const SECRET = process.env.SECRET || ''
|
||||
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||
|
||||
if (!EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
|
||||
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
|
||||
|
||||
// console.log("generate env:",EXTERNAL_API_KEY,TMPL_PATH,PRIVATE_KEY_HEX,SECRET)
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
|
||||
const apiKey = request.headers.get('x-api-key');
|
||||
|
||||
if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized: Invalid API Key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// รับข้อมูล JSON Payload
|
||||
const templateData:DocumentTemplate = (await request.json());
|
||||
// console.log(templateData)
|
||||
// ต้องมี templateData.info.ticket_data เป็น json string เสมอ
|
||||
if (!templateData || !templateData.info ||
|
||||
!templateData.info.ticket_data ||
|
||||
typeof templateData.info.ticket_data !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "template/generate json.info.ticket_data not avaliable",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const ticketObj = JSON.parse(templateData.info.ticket_data)
|
||||
const tid = String(ticketObj.tid)
|
||||
const fid = String(ticketObj.fid)
|
||||
const uid = String(ticketObj.uid)
|
||||
if(!tid||!fid||!uid)
|
||||
{
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "template/generate require json.info.ticket_data.(fid|tid|uid)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//เปิดไฟล์ template
|
||||
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`);
|
||||
let templateBuffer: Buffer = await readFile(tmplPath)
|
||||
|
||||
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);
|
||||
|
||||
// สร้าง ReadableStream ของไฟล์ .docx
|
||||
// ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System
|
||||
// const encoder = new TextEncoder();
|
||||
|
||||
const docxStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(output);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word
|
||||
const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`)
|
||||
return new NextResponse(docxStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${dlFileName}.docx"`,
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
});
|
||||
} catch (error:any) {
|
||||
if ( error.code === 'ENOENT') {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound: Invalid template ID or incorrect storage path'},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
console.error('Error generating docx:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate document payload'},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
app/api/template/render/route.ts
Normal file
135
app/api/template/render/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const template = formData.get("template");
|
||||
const dataFile = formData.get("data");
|
||||
const dataJson = formData.get("dataJson");
|
||||
const TEST_SECRET = String(formData.get("secret")||"")
|
||||
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
|
||||
|
||||
// ตรวจสอบ Template
|
||||
if (!(template instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing DOCX file in field "template".',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (template.type !== DOCX_CONTENT_TYPE &&
|
||||
!template.name.toLowerCase().endsWith(".docx") &&
|
||||
template.size > MAX_UPLOAD_BYTES
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// อ่าน JSON
|
||||
let templateData: DocumentTemplate;
|
||||
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
||||
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
||||
try {
|
||||
if (typeof dataJson === "string" && dataJson.length > 0) {
|
||||
// กรณีส่ง JSON ผ่าน form field
|
||||
templateData = JSON.parse(dataJson) as DocumentTemplate;
|
||||
} else if (dataFile instanceof File) {
|
||||
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Field "data" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// กรณี upload data.json
|
||||
const text = await dataFile.text();
|
||||
|
||||
templateData = JSON.parse(text) as DocumentTemplate;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
|
||||
}).`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// JSON ต้องเป็น object
|
||||
if (
|
||||
templateData === null ||
|
||||
typeof templateData !== "object" ||
|
||||
Array.isArray(templateData)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "JSON data must be an object.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Template File → Buffer
|
||||
// -----------------------------
|
||||
|
||||
let templateBuffer: Buffer = Buffer.from(
|
||||
await template.arrayBuffer(),
|
||||
);
|
||||
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC);
|
||||
|
||||
// -----------------------------
|
||||
// ส่ง DOCX กลับ
|
||||
// -----------------------------
|
||||
if(templateData?.info?.ticket_data)
|
||||
return new NextResponse(output as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": DOCX_CONTENT_TYPE,
|
||||
"Content-Disposition":
|
||||
'attachment; filename="generated-document.docx"',
|
||||
"Content-Length": String(output.length),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Internal Server Error",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
10
app/api/template/test/route.ts
Normal file
10
app/api/template/test/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: "docx-template-api",
|
||||
});
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ export default async function AnnotatePage({
|
|||
|
||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "@/db/schema";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||
|
|
@ -134,7 +134,7 @@ export default async function SummaryPage({
|
|||
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
|
|
@ -154,7 +154,7 @@ export default async function SummaryPage({
|
|||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้าอ่าน
|
||||
|
|
@ -7,9 +7,9 @@ import { firstSearchParam } from "@/utils/external-file";
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /?fileUrl= และ /annotate?fileUrl=)
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
|
||||
*/
|
||||
export default async function AnnotateEntryPage({
|
||||
searchParams,
|
||||
|
|
@ -24,7 +24,7 @@ export default async function AnnotateEntryPage({
|
|||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
|
|
@ -34,7 +34,7 @@ export default async function AnnotateEntryPage({
|
|||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
|
|
@ -43,7 +43,7 @@ export default async function AnnotateEntryPage({
|
|||
);
|
||||
}
|
||||
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
|
||||
|
|
@ -53,7 +53,7 @@ export default async function AnnotateEntryPage({
|
|||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
|
|
@ -130,7 +130,7 @@ export default async function GroupPage({
|
|||
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
|
|
@ -22,7 +22,7 @@ export default function LoginPage({
|
|||
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect(next ?? "/");
|
||||
if (user) redirect(next ?? "/note");
|
||||
|
||||
return (
|
||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||
236
app/note/page.tsx
Normal file
236
app/note/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type FileRow = typeof files.$inferSelect;
|
||||
|
||||
/** badge ชนิดไฟล์ต่อรายการ (N3) */
|
||||
function kindBadge(pdfUrl: string): string {
|
||||
switch (imageFormatFromUrl(pdfUrl)) {
|
||||
case "jpeg":
|
||||
return "JPG";
|
||||
case "png":
|
||||
return "PNG";
|
||||
case "tiff":
|
||||
return "TIFF";
|
||||
default:
|
||||
return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
|
||||
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
|
||||
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
|
||||
function FileCard({ file }: { file: FileRow }) {
|
||||
return (
|
||||
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{file.title}
|
||||
</span>
|
||||
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
|
||||
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
|
||||
<span className="mt-1 block text-caption text-ink-soft">
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
|
||||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
}
|
||||
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
|
||||
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
|
||||
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
|
||||
const grouped = new Map<string, FileRow[]>();
|
||||
const ungrouped: FileRow[] = [];
|
||||
for (const f of rows) {
|
||||
if (f.typeId) {
|
||||
const list = grouped.get(f.typeId);
|
||||
if (list) list.push(f);
|
||||
else grouped.set(f.typeId, [f]);
|
||||
} else {
|
||||
ungrouped.push(f);
|
||||
}
|
||||
}
|
||||
const sections = Array.from(grouped.entries()).sort(
|
||||
(a, b) =>
|
||||
b[1][b[1].length - 1].createdAt.getTime() -
|
||||
a[1][a[1].length - 1].createdAt.getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
|
||||
summary-page ของหน้าสรุป) */}
|
||||
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
|
||||
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
|
||||
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
|
||||
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
|
||||
ฟอร์มอัปโหลด) */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
เปิดเอกสารจากลิงก์ e-Court
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<EcourtLinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{rows.length} ไฟล์
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
|
||||
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
|
||||
<div className="mt-5 max-w-xl">
|
||||
<UploadFileForm />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
|
||||
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<Folder
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
|
||||
{typeId}
|
||||
</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/note/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,13 +7,13 @@ import {
|
|||
} from "@/utils/file-kind";
|
||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
|
||||
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
|
||||
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
|
||||
// เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||
|
||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
||||
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
||||
245
app/page.tsx
245
app/page.tsx
|
|
@ -1,236 +1,33 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
|
||||
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
|
||||
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
|
||||
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
|
||||
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
|
||||
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
|
||||
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
|
||||
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type FileRow = typeof files.$inferSelect;
|
||||
|
||||
/** badge ชนิดไฟล์ต่อรายการ (N3) */
|
||||
function kindBadge(pdfUrl: string): string {
|
||||
switch (imageFormatFromUrl(pdfUrl)) {
|
||||
case "jpeg":
|
||||
return "JPG";
|
||||
case "png":
|
||||
return "PNG";
|
||||
case "tiff":
|
||||
return "TIFF";
|
||||
default:
|
||||
return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
|
||||
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
|
||||
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
|
||||
function FileCard({ file }: { file: FileRow }) {
|
||||
return (
|
||||
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{file.title}
|
||||
</span>
|
||||
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
|
||||
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
|
||||
<span className="mt-1 block text-caption text-ink-soft">
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
|
||||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
export default function RootPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /annotate?fileUrl=… — app/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
const entries = Object.entries(searchParams);
|
||||
const isEcourtLink = entries.some(([key]) =>
|
||||
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
|
||||
);
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
if (!isEcourtLink) {
|
||||
redirect("/wordaddin");
|
||||
}
|
||||
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
|
||||
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
|
||||
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
|
||||
const grouped = new Map<string, FileRow[]>();
|
||||
const ungrouped: FileRow[] = [];
|
||||
for (const f of rows) {
|
||||
if (f.typeId) {
|
||||
const list = grouped.get(f.typeId);
|
||||
if (list) list.push(f);
|
||||
else grouped.set(f.typeId, [f]);
|
||||
} else {
|
||||
ungrouped.push(f);
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of entries) {
|
||||
if (typeof value === "string") {
|
||||
qs.set(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) qs.append(key, v);
|
||||
}
|
||||
}
|
||||
const sections = Array.from(grouped.entries()).sort(
|
||||
(a, b) =>
|
||||
b[1][b[1].length - 1].createdAt.getTime() -
|
||||
a[1][a[1].length - 1].createdAt.getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
|
||||
summary-page ของหน้าสรุป) */}
|
||||
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
|
||||
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
|
||||
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
|
||||
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
|
||||
ฟอร์มอัปโหลด) */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
เปิดเอกสารจากลิงก์ e-Court
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<EcourtLinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{rows.length} ไฟล์
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
|
||||
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
|
||||
<div className="mt-5 max-w-xl">
|
||||
<UploadFileForm />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
|
||||
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<Folder
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
|
||||
{typeId}
|
||||
</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
redirect(`/note?${qs.toString()}`);
|
||||
}
|
||||
|
|
|
|||
101
app/tmpl/page.tsx
Normal file
101
app/tmpl/page.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// app/page.tsx (ระบบ B)
|
||||
'use client';
|
||||
import Link from "next/link";
|
||||
import type { ChangeEvent } from 'react';
|
||||
// import { createDownloadFormAction } from './actions';
|
||||
import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown';
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
const templateData: DropdownData = {
|
||||
T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'],
|
||||
T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'],
|
||||
};
|
||||
const handleTmplSelect = (template: string, data: string) => {
|
||||
//console.log('Location Selected:', { template, data });
|
||||
};
|
||||
const handleInput = (e: ChangeEvent<HTMLInputElement>)=>{
|
||||
// console.log(e.target)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
|
||||
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
|
||||
<div>
|
||||
ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET)
|
||||
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
||||
ตัวอย่างหน้าแบบ <Link href='/tmpl/dl.html'>Plain HTML</Link>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<form action="/docedit/api/template/download-docx" method="POST">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
|
||||
<DependentDropdown data={templateData} name1='tid' name2='fid'
|
||||
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
|
||||
<div className="mb-2"></div>
|
||||
<div>
|
||||
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
|
||||
</div>
|
||||
<div>
|
||||
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0070f3',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
ดาวน์โหลดเอกสาร MS Word
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">A Link Get(new)</h2>
|
||||
<Link href="/api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</Link><br/>
|
||||
<Link href="/api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</Link><br/>
|
||||
<Link href="/api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</Link><br/>
|
||||
|
||||
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</Link><br/>
|
||||
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</Link><br/>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
|
||||
Template: <Link href="/tmpl/T_X.docx">T_X.docx</Link>,
|
||||
<Link href="/tmpl/T12_X.docx">T12_X.docx</Link><br/>
|
||||
Data: <Link href="tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</Link>,
|
||||
<Link href="/tmpl/T_no_warning.json">T_no_warning.json</Link>,
|
||||
<Link href="/tmpl/TS_P2_IN.json">TS_P2_IN.json</Link><br/>
|
||||
<Link href="/tmpl/T12_b2_IN.json">T12_b2_IN.json</Link>,
|
||||
<Link href="/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</Link>
|
||||
</div>
|
||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
||||
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
|
||||
<div className="text-xl font-bold text-center">
|
||||
หน้านี้(เลือกไฟล์)<br/>
|
||||
↓ <br/>
|
||||
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
|
||||
↓ <br/>
|
||||
generate(เปิดเทมเพลต,สร้าง docx)<br/>
|
||||
↓ <br/>
|
||||
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
|
||||
↓ <br/>
|
||||
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
|
||||
↓ <br/>
|
||||
Versioning(คุมเวอร์ชั่น)<br/>
|
||||
↓ <br/>
|
||||
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
app/wordaddin/error.tsx
Normal file
37
app/wordaddin/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า
|
||||
// "Application error: a client-side exception has occurred" ของ Next
|
||||
// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร
|
||||
// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้)
|
||||
export default function WordAddinError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col gap-3 p-4">
|
||||
<h2 className="font-display text-base font-semibold text-ink">task pane ทำงานผิดพลาด</h2>
|
||||
<p className="text-sm text-ink-soft">
|
||||
เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่
|
||||
</p>
|
||||
<pre
|
||||
className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-canvas p-3 text-xs text-error-ink"
|
||||
aria-label="รายละเอียดข้อผิดพลาด"
|
||||
>
|
||||
{error.message}
|
||||
{error.digest ? `\n[digest ${error.digest}]` : ""}
|
||||
{error.stack ? `\n\n${error.stack}` : ""}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring w-fit rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-primary hover:text-primary"
|
||||
onClick={reset}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/wordaddin/layout.tsx
Normal file
10
app/wordaddin/layout.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
|
||||
export const metadata: Metadata = {
|
||||
title: "e-Court Documents Versioning",
|
||||
};
|
||||
|
||||
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
33
app/wordaddin/manifest.xml/route.ts
Normal file
33
app/wordaddin/manifest.xml/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { requestOrigin } from "@/utils/request-origin";
|
||||
|
||||
// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload
|
||||
//
|
||||
// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม
|
||||
// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้
|
||||
// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route
|
||||
// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว
|
||||
// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template
|
||||
// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น)
|
||||
//
|
||||
// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker
|
||||
// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image)
|
||||
|
||||
/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */
|
||||
const DEV_ORIGIN = "https://localhost:3000";
|
||||
|
||||
const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml");
|
||||
|
||||
export function GET(req: Request): Response {
|
||||
const template = fs.readFileSync(TEMPLATE, "utf8");
|
||||
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
|
||||
return new Response(manifest, {
|
||||
headers: {
|
||||
"content-type": "application/xml; charset=utf-8",
|
||||
// origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
7
app/wordaddin/page.tsx
Normal file
7
app/wordaddin/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
|
||||
|
||||
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
|
||||
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
|
||||
export default function WordAddinPage() {
|
||||
return <WordAddinGate />;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
|
||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||
// เป็นแถบของตัวเองกลางหน้า
|
||||
import Link from "next/link";
|
||||
|
|
@ -15,7 +15,7 @@ export default function AppNav({ userName }: { userName: string }) {
|
|||
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
|
||||
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useEffect } from "react";
|
|||
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
|
||||
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
|
||||
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
|
||||
* app/annotate/[id]/page.tsx */
|
||||
* app/note/annotate/[id]/page.tsx */
|
||||
export default function DebugConsole() {
|
||||
useEffect(() => {
|
||||
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function EcourtLinkForm() {
|
|||
}
|
||||
setError(null);
|
||||
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
||||
router.push(`/annotate?${queryString}`);
|
||||
router.push(`/note/annotate?${queryString}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
|
||||
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
|
||||
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
|
||||
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
|
||||
|
|
@ -36,7 +36,7 @@ export default function LoginForm({ next }: { next: string | null }) {
|
|||
try {
|
||||
const result = await login(usernameInput.value, passwordInput.value);
|
||||
if (result.ok) {
|
||||
router.replace(next ?? "/");
|
||||
router.replace(next ?? "/note");
|
||||
} else {
|
||||
setError(result.error);
|
||||
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action
|
||||
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
|
||||
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
|
||||
import { useState, useTransition } from "react";
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ function RowNote({ text }: { text: string }) {
|
|||
function OpenFileButton({ file }: { file: GroupFileSummary }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
{file.isImage ? (
|
||||
|
|
@ -178,8 +178,8 @@ export function GroupList({
|
|||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||
const href = isBm
|
||||
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||
? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||
: `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||
return (
|
||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
|
||||
|
|
|
|||
|
|
@ -6,7 +6,15 @@ import {
|
|||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
BookmarkPlus,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
AnnotationData,
|
||||
|
|
@ -15,15 +23,19 @@ import type {
|
|||
HighlightData,
|
||||
HighlightStyle,
|
||||
TextboxData,
|
||||
TextboxSegment,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
COMMENT_PIN_COARSE_SIZE_PX,
|
||||
COMMENT_PIN_SIZE_PX,
|
||||
TEXTBOX_MAX_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
clampStampIntoPage,
|
||||
commentPinBoxPct,
|
||||
commentPinBoxPx,
|
||||
rotatePoint,
|
||||
scaleAnnotationData,
|
||||
stampRect,
|
||||
translateAnnotationData,
|
||||
} from "@/utils/geometry";
|
||||
import {
|
||||
|
|
@ -33,9 +45,40 @@ import {
|
|||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { annotationTypeLabel, STAMP_COLOR } from "@/utils/annotation-display";
|
||||
import {
|
||||
annotationTypeLabel,
|
||||
measureStampText,
|
||||
STAMP_COLOR,
|
||||
} from "@/utils/annotation-display";
|
||||
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
|
||||
|
||||
/** Patch ของตัวควบคุมเล็กในแผง selection / editor ข้อความ — ส่งเฉพาะ field ที่แตะ
|
||||
* ให้ viewer ผสานเข้ากับ data ล่าสุด**ตอนถึงคิว update** ไม่ใช่ snapshot ของ render
|
||||
* นี้ — คอมมิตแบบ full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (จอสัมผัส:
|
||||
* blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผงต่อทันทีก่อน React render
|
||||
* optimistic row — code review 2026-09-28 รอบสอง) */
|
||||
export type AnnotationPatch =
|
||||
/** สี — ชื่อ field ตามชนิด: HIGHLIGHT/STAMP = `color`, DRAW/SHAPE = `strokeColor` */
|
||||
| { kind: "color"; color: string }
|
||||
/** ความหนา — DRAW สเกล `widths` ต่อจุดตามอัตราส่วนตอนผสาน (เหมือนเดิม) */
|
||||
| { kind: "strokeWidth"; strokeWidth: number }
|
||||
| { kind: "highlightStyle"; style: HighlightStyle }
|
||||
/** ความทึบของ HIGHLIGHT — 100 = ถอด field `opacity` ทิ้งเหมือนตอนสร้างใหม่ */
|
||||
| { kind: "highlightOpacity"; opacityPct: number }
|
||||
| { kind: "stampScale"; scale: number }
|
||||
/** STAMP — มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ; 0 = ถอด field เหมือนแถวเดิม) */
|
||||
| { kind: "stampRotation"; rotation: number }
|
||||
| {
|
||||
kind: "textbox";
|
||||
fields: Partial<
|
||||
Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">
|
||||
>;
|
||||
}
|
||||
/** StampEditor — แก้คำตรายาง */
|
||||
| { kind: "label"; label: string }
|
||||
/** TextboxEditor — แก้ข้อความกล่องข้อความ (segments null = เคลียร์ตัวหนาทั้งก้อน) */
|
||||
| { kind: "textboxText"; text: string; segments: TextboxSegment[] | null };
|
||||
|
||||
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
|
||||
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
|
||||
const FOCUS_RING_CLS =
|
||||
|
|
@ -50,6 +93,8 @@ const FOCUS_RING_CLS =
|
|||
|
||||
const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
|
||||
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
|
||||
const ROT_HANDLE_PX = 18; // จุดจับหมุนตรายาง (STAMP) — วงกลมเหนือกึ่งกลางขอบบน
|
||||
const ROT_HANDLE_GAP_PX = 20; // ระยะแนวฉากจากขอบบนกรอบถึงจุดจับหมุน
|
||||
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
|
||||
const EPS = 1e-6;
|
||||
|
||||
|
|
@ -58,15 +103,19 @@ const EPS = 1e-6;
|
|||
|
||||
type DragState = {
|
||||
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||||
mode: "move" | "scale" | "wleft" | "wright";
|
||||
mode: "move" | "scale" | "wleft" | "wright" | "rotate";
|
||||
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
||||
startPct: Point;
|
||||
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
||||
origData: AnnotationData;
|
||||
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
|
||||
/** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */
|
||||
origBoxPct: Rect;
|
||||
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
|
||||
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */
|
||||
anchorPct: Point;
|
||||
/** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง
|
||||
* แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ
|
||||
* กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */
|
||||
rot?: number;
|
||||
};
|
||||
|
||||
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
|
||||
|
|
@ -103,6 +152,7 @@ export default function AnnotationSelectionLayer({
|
|||
onDelete,
|
||||
onBookmark,
|
||||
onStartEdit,
|
||||
onPatch,
|
||||
}: {
|
||||
annotation: AnnotationDTO;
|
||||
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
|
||||
|
|
@ -124,11 +174,35 @@ export default function AnnotationSelectionLayer({
|
|||
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
|
||||
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
|
||||
onStartEdit?: (id: string) => void;
|
||||
/** ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) commit เป็น
|
||||
* patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว update กัน stale base ทับค่าที่เพิ่ง
|
||||
* เปลี่ยน (ดู AnnotationPatch) */
|
||||
onPatch: (id: string, patch: AnnotationPatch) => void;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const movedRef = useRef(false);
|
||||
const latestRef = useRef<AnnotationData | null>(null);
|
||||
// rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8
|
||||
// ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน
|
||||
// latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม
|
||||
const previewRafRef = useRef<number | null>(null);
|
||||
|
||||
/** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น
|
||||
* callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */
|
||||
function cancelPreviewRaf() {
|
||||
if (previewRafRef.current === null) return;
|
||||
cancelAnimationFrame(previewRafRef.current);
|
||||
previewRafRef.current = null;
|
||||
}
|
||||
|
||||
// เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelPreviewRaf();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
|
||||
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
|
||||
|
|
@ -143,12 +217,44 @@ export default function AnnotationSelectionLayer({
|
|||
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
|
||||
const isCommentPin = annotation.type === "COMMENT";
|
||||
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
|
||||
// bbox ของ STAMP ส่ง measureStampText ให้ความสูงตรงกับที่ StampItem render จริง
|
||||
// (ประมาณแล้วขอบล่างกรอบเกินตรายาง — bug 2026-09-28)
|
||||
const boxPx = isCommentPin
|
||||
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
|
||||
: annotationBBox(annotation, size);
|
||||
: annotationBBox(annotation, size, measureStampText);
|
||||
const boxPct = isCommentPin
|
||||
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
|
||||
: annotationBBox(annotation, { width: 100, height: 100 });
|
||||
: annotationBBox(annotation, { width: 100, height: 100 }, measureStampText);
|
||||
|
||||
// STAMP เอียง (rotation ≠ 0) — กรอบ+handles วาดที่ตำแหน่งกรอบ*ก่อนหมุน* แล้วหมุน
|
||||
// ทั้งชุดด้วย CSS transform รอบกลางกรอบ (ตรงกับที่ StampItem render) ส่วน boxPx/
|
||||
// boxPct ด้านบนเป็น AABB ของกรอบหมุนแล้ว ใช้กับ clamp ย้าย/แผงเครื่องมือ/ป้ายชื่อ
|
||||
// stampData narrow ด้วย "label" in ตรงนี้แล้วส่งเข้า stampRect ตรง ๆ — ไม่ as-cast
|
||||
// (cast พังเงียบถ้าเงื่อนไขแวดข้างเปลี่ยน — code review 2026-09-29)
|
||||
const stampData = "label" in annotation.data ? annotation.data : null;
|
||||
const stampRot = stampData?.rotation ?? 0;
|
||||
const isRotStamp = stampRot !== 0;
|
||||
const framePx =
|
||||
stampData && isRotStamp ? stampRect(stampData, size, measureStampText) : boxPx;
|
||||
const framePct =
|
||||
stampData && isRotStamp
|
||||
? stampRect(stampData, { width: 100, height: 100 }, measureStampText)
|
||||
: boxPct;
|
||||
// transform หมุนทั้งชุดกรอบ — origin เป็น**กลางกรอบในพิกัดของ element นั้น ๆ** (CSS
|
||||
// transform-origin วัดจากมุมซ้ายบนของ element เอง ไม่ใช่ของ page — จุดกลางกรอบ
|
||||
// ในพิกัด page ลบด้วย left/top ของ element จึงได้ origin ที่ทุก div หมุนไปตรงจุด
|
||||
// เดียวกัน ตามตัวตรายาง — bug "กรอบไม่ตรงตัวเมื่อเอียง" 2026-09-28)
|
||||
const rotC = {
|
||||
x: framePx.x + framePx.width / 2,
|
||||
y: framePx.y + framePx.height / 2,
|
||||
};
|
||||
const rotOn = (elLeft: number, elTop: number) =>
|
||||
isRotStamp
|
||||
? {
|
||||
transform: `rotate(${stampRot}deg)`,
|
||||
transformOrigin: `${rotC.x - elLeft}px ${rotC.y - elTop}px`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
|
||||
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
|
||||
|
|
@ -160,7 +266,7 @@ export default function AnnotationSelectionLayer({
|
|||
|
||||
function beginDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
mode: "move" | "scale" | "wleft" | "wright",
|
||||
mode: "move" | "scale" | "wleft" | "wright" | "rotate",
|
||||
anchorPct: Point,
|
||||
) {
|
||||
if (!editable || e.button !== 0) return;
|
||||
|
|
@ -173,6 +279,7 @@ export default function AnnotationSelectionLayer({
|
|||
origData: annotation.data,
|
||||
origBoxPct: boxPct,
|
||||
anchorPct,
|
||||
...(stampRot !== 0 ? { rot: stampRot } : null),
|
||||
};
|
||||
movedRef.current = false;
|
||||
latestRef.current = null;
|
||||
|
|
@ -184,7 +291,30 @@ export default function AnnotationSelectionLayer({
|
|||
e.stopPropagation();
|
||||
const cur = pointerPct(e);
|
||||
let next: AnnotationData;
|
||||
if (st.mode === "move") {
|
||||
if (st.mode === "rotate") {
|
||||
// STAMP — ลากหมุน: มุมใหม่ = มุมเดิม + มุมที่ pointer กวาดรอบ**กลางกรอบ**
|
||||
// (atan2 ในพิกัดจอ y ลง = ทิศเดียวกับ CSS rotate) — snap ดูดเข้าทวีคูณ 15°
|
||||
// เมื่อใกล้ ≤4° ให้ตั้ง 0/±45/±90 เร็ว แล้ว wrap เข้า (−180, 180] เก็บ 1 ทศนิยม
|
||||
// (0° ถอด field เหมือน patch ของแผง — anchorPct = กลางกรอบจาก AABB)
|
||||
const ctr = st.anchorPct;
|
||||
const a0 = Math.atan2(st.startPct.y - ctr.y, st.startPct.x - ctr.x);
|
||||
const a1 = Math.atan2(cur.y - ctr.y, cur.x - ctr.x);
|
||||
let deg = (st.rot ?? 0) + ((a1 - a0) * 180) / Math.PI;
|
||||
const snap = Math.round(deg / 15) * 15;
|
||||
if (Math.abs(deg - snap) <= 4) deg = snap;
|
||||
deg = Number(((((deg + 180) % 360) + 360) % 360 - 180).toFixed(1));
|
||||
if ("label" in st.origData) {
|
||||
const { rotation: _prev, ...rest } = st.origData;
|
||||
// หมุนจนกรอบยื่นเกินขอบหน้า = เลื่อนกลับเข้าหน้าทันทีระหว่างลาก (เดียวกับ
|
||||
// patch ของแผง — validator ตรวจ AABB ไม่ติดลบ, code review 2026-09-28)
|
||||
next =
|
||||
deg === 0
|
||||
? rest
|
||||
: { ...rest, rotation: deg, position: clampStampIntoPage({ ...rest, rotation: deg }) };
|
||||
} else {
|
||||
next = st.origData;
|
||||
}
|
||||
} else if (st.mode === "move") {
|
||||
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
|
||||
const dx = clamp(
|
||||
cur.x - st.startPct.x,
|
||||
|
|
@ -228,12 +358,29 @@ export default function AnnotationSelectionLayer({
|
|||
next = { ...d, width: w, position: { ...d.position, x: left } };
|
||||
}
|
||||
} else if ("scale" in st.origData) {
|
||||
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
||||
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||||
// STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label,
|
||||
// stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||||
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
|
||||
// · STAMP เอียง: อัตราส่วนต้องวัดในแกนของ*กรอบ* — ย้อนหมุน pointer รอบกลางกรอบ
|
||||
// ก่อน (แกน x/y ของสูตรคือแกนกรอบ ไม่ใช่แกนจอ) ส่วน clamp กับ scaleAnnotationData
|
||||
// ใช้พิกัดจอ: มุมกรอบหมุนทั้งชุดโดย scale-uniform รอบ anchor จริง ๆ (AABB ของ
|
||||
// กรอบหมุน scale ตาม k รอบ anchor เช่นกัน) clamp ด้วยขอบ AABB จึงยังถูกต้อง
|
||||
const rot = st.rot ?? 0;
|
||||
let curL = cur;
|
||||
let startL = st.startPct;
|
||||
let anchorL = st.anchorPct;
|
||||
if (rot) {
|
||||
const ctr = {
|
||||
x: st.origBoxPct.x + st.origBoxPct.width / 2,
|
||||
y: st.origBoxPct.y + st.origBoxPct.height / 2,
|
||||
};
|
||||
curL = rotatePoint(cur, ctr, -rot);
|
||||
startL = rotatePoint(st.startPct, ctr, -rot);
|
||||
anchorL = rotatePoint(st.anchorPct, ctr, -rot);
|
||||
}
|
||||
const rawK =
|
||||
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
|
||||
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
|
||||
((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) +
|
||||
(curL.y - anchorL.y) / (startL.y - anchorL.y || EPS)) /
|
||||
2;
|
||||
const k = clampAxisScale(
|
||||
clampAxisScale(
|
||||
|
|
@ -268,7 +415,14 @@ export default function AnnotationSelectionLayer({
|
|||
}
|
||||
movedRef.current = true;
|
||||
latestRef.current = next;
|
||||
onPreview(next);
|
||||
// render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม
|
||||
// ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29)
|
||||
if (previewRafRef.current === null) {
|
||||
previewRafRef.current = requestAnimationFrame(() => {
|
||||
previewRafRef.current = null;
|
||||
if (dragRef.current && latestRef.current) onPreview(latestRef.current);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag(e: ReactPointerEvent<HTMLElement>) {
|
||||
|
|
@ -276,6 +430,7 @@ export default function AnnotationSelectionLayer({
|
|||
if (!st) return;
|
||||
e.stopPropagation();
|
||||
dragRef.current = null;
|
||||
cancelPreviewRaf();
|
||||
onPreview(null);
|
||||
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
|
||||
movedRef.current = false;
|
||||
|
|
@ -285,59 +440,40 @@ export default function AnnotationSelectionLayer({
|
|||
function cancelDrag() {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
cancelPreviewRaf();
|
||||
movedRef.current = false;
|
||||
latestRef.current = null;
|
||||
onPreview(null);
|
||||
}
|
||||
|
||||
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
|
||||
/** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม
|
||||
* ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */
|
||||
function commitColor(color: string) {
|
||||
const d = annotation.data;
|
||||
if ("rects" in d) onCommit(annotation.id, { ...d, color });
|
||||
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
|
||||
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
|
||||
else if ("label" in d) onCommit(annotation.id, { ...d, color });
|
||||
onPatch(annotation.id, { kind: "color", color });
|
||||
}
|
||||
|
||||
function commitStrokeWidth(w: number) {
|
||||
const d = annotation.data;
|
||||
if ("paths" in d) {
|
||||
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
|
||||
onCommit(annotation.id, {
|
||||
...d,
|
||||
strokeWidth: w,
|
||||
...(d.widths
|
||||
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
|
||||
: {}),
|
||||
});
|
||||
} else if ("shape" in d) {
|
||||
onCommit(annotation.id, { ...d, strokeWidth: w });
|
||||
}
|
||||
onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w });
|
||||
}
|
||||
|
||||
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
|
||||
function commitHighlightStyle(style: HighlightStyle) {
|
||||
const d = annotation.data;
|
||||
if (!("rects" in d)) return;
|
||||
onCommit(annotation.id, { ...d, style });
|
||||
onPatch(annotation.id, { kind: "highlightStyle", style });
|
||||
}
|
||||
|
||||
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
|
||||
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */
|
||||
function commitHighlightOpacity(opacityPct: number) {
|
||||
const d = annotation.data;
|
||||
if (!("rects" in d)) return;
|
||||
const { opacity: _prev, ...rest } = d;
|
||||
onCommit(annotation.id, {
|
||||
...rest,
|
||||
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
|
||||
});
|
||||
onPatch(annotation.id, { kind: "highlightOpacity", opacityPct });
|
||||
}
|
||||
|
||||
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
|
||||
function commitStampScale(scale: number) {
|
||||
const d = annotation.data;
|
||||
if (!("label" in d)) return;
|
||||
onCommit(annotation.id, { ...d, scale });
|
||||
onPatch(annotation.id, { kind: "stampScale", scale });
|
||||
}
|
||||
|
||||
/** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */
|
||||
function commitStampRotation(rotation: number) {
|
||||
onPatch(annotation.id, { kind: "stampRotation", rotation });
|
||||
}
|
||||
|
||||
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
|
||||
|
|
@ -345,7 +481,7 @@ export default function AnnotationSelectionLayer({
|
|||
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
|
||||
) {
|
||||
if (!textboxData) return;
|
||||
onCommit(annotation.id, { ...textboxData, ...patch });
|
||||
onPatch(annotation.id, { kind: "textbox", fields: patch });
|
||||
}
|
||||
|
||||
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
|
||||
|
|
@ -356,8 +492,10 @@ export default function AnnotationSelectionLayer({
|
|||
const highlightData: HighlightData | null =
|
||||
"rects" in annotation.data ? annotation.data : null;
|
||||
const strokeData =
|
||||
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
|
||||
const stampData = "label" in annotation.data ? annotation.data : null;
|
||||
"paths" in annotation.data || "shape" in annotation.data
|
||||
? annotation.data
|
||||
: null;
|
||||
// (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง)
|
||||
const textboxData: TextboxData | null =
|
||||
"fontSize" in annotation.data ? annotation.data : null;
|
||||
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
|
||||
|
|
@ -368,36 +506,48 @@ export default function AnnotationSelectionLayer({
|
|||
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
|
||||
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
|
||||
|
||||
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
||||
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
||||
// มุมทั้ง 4 — ตำแหน่งวางเป็น px ของกรอบ*ก่อนหมุน* (STAMP เอียง: div ทั้งชุดถูกหมุน
|
||||
// ทีหลังผ่าน rotStyle), anchor มุมตรงข้ามเป็น % **ตำแหน่งจริงบนจอ** — STAMP เอียง
|
||||
// ต้องหมุนมุมกลับออกมาก่อน (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
||||
const frameCtrPct = isRotStamp
|
||||
? {
|
||||
x: framePct.x + framePct.width / 2,
|
||||
y: framePct.y + framePct.height / 2,
|
||||
}
|
||||
: null;
|
||||
const anchorOnScreen = (p: Point): Point =>
|
||||
frameCtrPct ? rotatePoint(p, frameCtrPct, stampRot) : p;
|
||||
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
|
||||
{
|
||||
key: "nw",
|
||||
x: boxPx.x,
|
||||
y: boxPx.y,
|
||||
x: framePx.x,
|
||||
y: framePx.y,
|
||||
cursor: "nwse-resize",
|
||||
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
|
||||
anchor: anchorOnScreen({
|
||||
x: framePct.x + framePct.width,
|
||||
y: framePct.y + framePct.height,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "ne",
|
||||
x: boxPx.x + boxPx.width,
|
||||
y: boxPx.y,
|
||||
x: framePx.x + framePx.width,
|
||||
y: framePx.y,
|
||||
cursor: "nesw-resize",
|
||||
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
|
||||
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }),
|
||||
},
|
||||
{
|
||||
key: "sw",
|
||||
x: boxPx.x,
|
||||
y: boxPx.y + boxPx.height,
|
||||
x: framePx.x,
|
||||
y: framePx.y + framePx.height,
|
||||
cursor: "nesw-resize",
|
||||
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
|
||||
anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }),
|
||||
},
|
||||
{
|
||||
key: "se",
|
||||
x: boxPx.x + boxPx.width,
|
||||
y: boxPx.y + boxPx.height,
|
||||
x: framePx.x + framePx.width,
|
||||
y: framePx.y + framePx.height,
|
||||
cursor: "nwse-resize",
|
||||
anchor: { x: boxPct.x, y: boxPct.y },
|
||||
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y }),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -427,14 +577,16 @@ export default function AnnotationSelectionLayer({
|
|||
style={{ pointerEvents: "none" }}
|
||||
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
|
||||
>
|
||||
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
|
||||
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ · STAMP เอียง
|
||||
วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
|
||||
<div
|
||||
className="absolute border-2 border-dashed border-primary"
|
||||
style={{
|
||||
left: boxPx.x,
|
||||
top: boxPx.y,
|
||||
width: boxPx.width,
|
||||
height: boxPx.height,
|
||||
left: framePx.x,
|
||||
top: framePx.y,
|
||||
width: framePx.width,
|
||||
height: framePx.height,
|
||||
...rotOn(framePx.x, framePx.y),
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -447,11 +599,12 @@ export default function AnnotationSelectionLayer({
|
|||
<div
|
||||
className="absolute cursor-move touch-none sel-strip"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y - STRIP_PX / 2,
|
||||
width: boxPx.width + STRIP_PX,
|
||||
left: framePx.x - STRIP_PX / 2,
|
||||
top: framePx.y - STRIP_PX / 2,
|
||||
width: framePx.width + STRIP_PX,
|
||||
height: STRIP_PX,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(framePx.x - STRIP_PX / 2, framePx.y - STRIP_PX / 2),
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerMove={moveDrag}
|
||||
|
|
@ -461,11 +614,12 @@ export default function AnnotationSelectionLayer({
|
|||
<div
|
||||
className="absolute cursor-move touch-none sel-strip"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y + boxPx.height - STRIP_PX / 2,
|
||||
width: boxPx.width + STRIP_PX,
|
||||
left: framePx.x - STRIP_PX / 2,
|
||||
top: framePx.y + framePx.height - STRIP_PX / 2,
|
||||
width: framePx.width + STRIP_PX,
|
||||
height: STRIP_PX,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(framePx.x - STRIP_PX / 2, framePx.y + framePx.height - STRIP_PX / 2),
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerMove={moveDrag}
|
||||
|
|
@ -475,11 +629,12 @@ export default function AnnotationSelectionLayer({
|
|||
<div
|
||||
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
left: framePx.x - STRIP_PX / 2,
|
||||
top: framePx.y,
|
||||
width: STRIP_PX,
|
||||
height: boxPx.height,
|
||||
height: framePx.height,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(framePx.x - STRIP_PX / 2, framePx.y),
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
|
||||
|
|
@ -491,11 +646,12 @@ export default function AnnotationSelectionLayer({
|
|||
<div
|
||||
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
left: framePx.x + framePx.width - STRIP_PX / 2,
|
||||
top: framePx.y,
|
||||
width: STRIP_PX,
|
||||
height: boxPx.height,
|
||||
height: framePx.height,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(framePx.x + framePx.width - STRIP_PX / 2, framePx.y),
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
|
||||
|
|
@ -520,6 +676,7 @@ export default function AnnotationSelectionLayer({
|
|||
height: HANDLE_PX,
|
||||
cursor: c.cursor,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(c.x - HANDLE_PX / 2, c.y - HANDLE_PX / 2),
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
|
||||
onPointerMove={moveDrag}
|
||||
|
|
@ -528,6 +685,39 @@ export default function AnnotationSelectionLayer({
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn):
|
||||
ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้
|
||||
2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */}
|
||||
{stampData && (
|
||||
<div
|
||||
className="absolute flex touch-none sel-handle cursor-grab items-center justify-center rounded-full border border-primary bg-white text-primary shadow-lg"
|
||||
style={{
|
||||
left: rotC.x - ROT_HANDLE_PX / 2,
|
||||
top: framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
|
||||
width: ROT_HANDLE_PX,
|
||||
height: ROT_HANDLE_PX,
|
||||
pointerEvents: "auto",
|
||||
...rotOn(
|
||||
rotC.x - ROT_HANDLE_PX / 2,
|
||||
framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
|
||||
),
|
||||
}}
|
||||
title="ลากเพื่อหมุนตรายาง"
|
||||
aria-label="ลากเพื่อหมุนตรายาง"
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, "rotate", {
|
||||
x: boxPct.x + boxPct.width / 2,
|
||||
y: boxPct.y + boxPct.height / 2,
|
||||
})
|
||||
}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -623,6 +813,20 @@ export default function AnnotationSelectionLayer({
|
|||
)}
|
||||
{stampData && (
|
||||
<>
|
||||
{/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX):
|
||||
แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ /
|
||||
root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย
|
||||
hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
|
||||
<PanelRow label="ข้อความ">
|
||||
<button
|
||||
type="button"
|
||||
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onStartEdit?.(annotation.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
แก้ไขข้อความ
|
||||
</button>
|
||||
</PanelRow>
|
||||
<PanelRow>
|
||||
<ColorSwatches
|
||||
value={stampData.color ?? STAMP_COLOR}
|
||||
|
|
@ -640,16 +844,28 @@ export default function AnnotationSelectionLayer({
|
|||
onChange={commitStampScale}
|
||||
/>
|
||||
</PanelRow>
|
||||
<PanelRow label="มุมเอียง">
|
||||
<NumberCombo
|
||||
value={stampData.rotation ?? 0}
|
||||
options={[-45, -15, 0, 15, 45]}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
suffix="°"
|
||||
onChange={commitStampRotation}
|
||||
/>
|
||||
</PanelRow>
|
||||
</>
|
||||
)}
|
||||
{textboxData && (
|
||||
<>
|
||||
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
|
||||
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
|
||||
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect
|
||||
· sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
|
||||
<PanelRow label="ข้อความ">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onStartEdit?.(annotation.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { measureStampText } from "@/utils/annotation-display";
|
||||
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
|
||||
import type { InkStamp } from "./TextboxStampLayer";
|
||||
|
||||
|
|
@ -303,7 +304,7 @@ export default function InkLayer({
|
|||
// (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox)
|
||||
for (const s of stamps) {
|
||||
if (s.userId !== ownUserId) continue;
|
||||
const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
|
||||
const b = annotationBBox({ type: "STAMP", data: s.data }, measured, measureStampText);
|
||||
if (
|
||||
lp.x >= b.x - pad &&
|
||||
lp.x <= b.x + b.width + pad &&
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
|
||||
import { textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { segmentsToHtml, serializeEditor } from "@/utils/textbox-editor";
|
||||
import {
|
||||
measureStampText,
|
||||
STAMP_COLOR,
|
||||
stampBoxHeight,
|
||||
STAMP_FONT_STACK,
|
||||
stampLayout,
|
||||
STAMP_MAX_LABEL_CHARS,
|
||||
} from "@/utils/annotation-display";
|
||||
|
||||
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
|
||||
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
|
||||
// (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool)
|
||||
// - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง
|
||||
// = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13)
|
||||
// ส่วน STAMP แก้ข้อความผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (editingId ใช้ร่วมกับ TEXTBOX)
|
||||
// - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า
|
||||
|
||||
/** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */
|
||||
|
|
@ -20,9 +29,8 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
|
|||
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
|
||||
export type InkStamp = { id: string; userId: string; data: StampData };
|
||||
|
||||
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
|
||||
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
|
||||
const STAMP_FONT_STACK = "system-ui, sans-serif";
|
||||
// ฟอนต์ตรายางบนจอ + canvas measure ย้ายไป utils/annotation-display.ts แล้ว
|
||||
// (2026-09-28) ให้ preview ในเมนูของ tool ใช้ตัววัดเดียวกันกับตอน render จริง
|
||||
|
||||
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
|
||||
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
|
||||
|
|
@ -30,55 +38,6 @@ const STAMP_FONT_STACK = "system-ui, sans-serif";
|
|||
* ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */
|
||||
const TEXTBOX_FONT_STACK =
|
||||
"var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif";
|
||||
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
|
||||
function measureStampText(text: string, fontSize: number): number {
|
||||
if (typeof document === "undefined") return 0;
|
||||
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
|
||||
if (!stampMeasureCtx) return 0;
|
||||
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
|
||||
return stampMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
|
||||
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const runs = segments?.length ? segments : [{ text }];
|
||||
return runs
|
||||
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
|
||||
.join("")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */
|
||||
function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } {
|
||||
const segs: TextboxSegment[] = [];
|
||||
const push = (text: string, bold: boolean) => {
|
||||
if (!text) return;
|
||||
const last = segs[segs.length - 1];
|
||||
if (last && !!last.bold === bold) last.text += text;
|
||||
else segs.push({ text, ...(bold ? { bold: true } : {}) });
|
||||
};
|
||||
const walk = (node: Node, bold: boolean) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
push(node.nodeValue ?? "", bold);
|
||||
} else if (node.nodeName === "BR") {
|
||||
push("\n", bold);
|
||||
} else {
|
||||
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
|
||||
const kids = Array.from(node.childNodes);
|
||||
kids.forEach((c, i) => {
|
||||
walk(c, b);
|
||||
// DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่
|
||||
if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b);
|
||||
});
|
||||
}
|
||||
};
|
||||
walk(root, false);
|
||||
return { text: segs.map((s) => s.text).join(""), segments: segs };
|
||||
}
|
||||
|
||||
/** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B
|
||||
* ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */
|
||||
|
|
@ -115,12 +74,23 @@ function TextboxEditor({
|
|||
onCancel: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
// initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
|
||||
// uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
|
||||
const initialHtml = useMemo(
|
||||
() => ({ __html: segmentsToHtml(initialText, initialSegments) }),
|
||||
[initialText, initialSegments],
|
||||
);
|
||||
// จบ editor ได้ครั้งเดียว — blur (บันทึกอัตโนมัติ), Escape, unmount แต่ละทางเรียก
|
||||
// ต่างกันและคำสั่งที่สอง (blur ตามหลัง Escape/Enter ตอน element ถูกถอด, Enter ซ้ำ
|
||||
// จาก double-tap บนจอสัมผัสก่อน React ถอด editor) ต้องเงียบ — ไม่งั้น onSave ยิง
|
||||
// ซ้ำจน composer สร้างกล่องซ้ำ / update เดียวกันกลายเป็น 2 version ในประวัติ F19
|
||||
const doneRef = useRef(false);
|
||||
const finish = (fn: () => void) => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
fn();
|
||||
};
|
||||
// initial HTML freeze ตอน mount (useState initializer) — editor เป็น uncontrolled
|
||||
// (พิมพ์ใน DOM ตรง ๆ) ค่าเริ่มต้นต้องไม่เปลี่ยนตาม props กลางทาง: realtime update
|
||||
// แก้กล่องเดียวกัน (อีกแท็บ/อีกคน) แล้ว useMemo คืน __html ใหม่จะ reset innerHTML
|
||||
// เผลอข้อความที่ผู้ใช้กำลังพิมพ์
|
||||
const [initialHtml] = useState(() => ({
|
||||
__html: segmentsToHtml(initialText, initialSegments),
|
||||
}));
|
||||
|
||||
// focus + วาง caret ท้ายข้อความตอนเปิด editor
|
||||
useEffect(() => {
|
||||
|
|
@ -139,9 +109,11 @@ function TextboxEditor({
|
|||
const submit = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
finish(() => {
|
||||
const { text, segments } = serializeEditor(el);
|
||||
if (text.trim()) onSave(text, segments);
|
||||
else onCancel();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -172,17 +144,32 @@ function TextboxEditor({
|
|||
onBlur={submit}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer
|
||||
// Enter ระหว่าง IME compose (คีย์บอร์ดเสมือนของ iPad/Android, IME ภาษาอื่น)
|
||||
// = ยืนยันคำที่กำลังพิมพ์ ไม่ใช่คำสั่งของ editor
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
finish(onCancel);
|
||||
return;
|
||||
}
|
||||
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
|
||||
// Enter = ขึ้นบรรทัดใหม่เป็น <br> ล้วนเสมอ — ปล่อย browser สร้าง <div> ต่อ
|
||||
// บรรทัดเอง (Chrome) แล้ว serializeEditor ต้องเดา newline จากรอยต่อ block
|
||||
// จน "abc<div>def</div>" กลายเป็น "abcdef" (รายงาน review ข้อ 2, 2026-09-28)
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.execCommand("insertLineBreak");
|
||||
return;
|
||||
}
|
||||
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก — ปิดเมื่อกล่อง
|
||||
// หนาทั้งกล่อง: execCommand("bold") จะ toggle ออกเป็น span font-weight:normal
|
||||
// ที่ serialize ไม่เก็บ และตอน render segment ก็ inherit 700 ของกล่องอยู่ดี
|
||||
// (อยากปรับเป็นปกติ = ปิดตัวหนาทั้งกล่องที่แผง selection ก่อน)
|
||||
// FIX-12: document.execCommand ถูก mark deprecated แต่
|
||||
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
|
||||
// (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
|
||||
// (bold/insertLineBreak/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
|
||||
e.preventDefault();
|
||||
document.execCommand("bold");
|
||||
if (!bold) document.execCommand("bold");
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
|
|
@ -194,7 +181,9 @@ function TextboxEditor({
|
|||
e.clipboardData.getData("text/plain"),
|
||||
);
|
||||
}}
|
||||
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-ink-faint/70 bg-transparent p-0 caret-current focus:outline-none"
|
||||
// ขอบประใช้ outline (ไม่กิน layout): border 1px ใน border-box ทำให้พื้นที่ข้อความ
|
||||
// แคบกว่าตอน render จริง 2px จนตัดบรรทัดต่างจากที่จะบันทึก (review ข้อ 3)
|
||||
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded outline-1 outline-dashed outline-ink-faint/70 bg-transparent p-0 caret-current"
|
||||
style={{
|
||||
fontFamily: TEXTBOX_FONT_STACK,
|
||||
fontSize: toPx(fontSizePct, size.width),
|
||||
|
|
@ -209,6 +198,174 @@ function TextboxEditor({
|
|||
);
|
||||
}
|
||||
|
||||
/** แก้ข้อความตรายางในที่ (ปุ่ม "แก้ไขข้อความ" ในแผง selection) — กรอบ/สี/ฟอนต์/การ
|
||||
* จัดวาง (ย่อฟอนต์/wrap หลายบรรทัด) เหมือนตอน render จริงทุกอย่าง (WYSIWYG)
|
||||
* ยกเว้น**มุมเอียง**: editor แสดงตรง (0°) เสมอ — textarea เอียงพิมพ์ลำบากและ
|
||||
* กรอบครอบตำแหน่งเดิมอยู่แล้ว ปิด editor แล้วตรายางกลับไปเอียงตามค่าเดิมทันที ·
|
||||
* พิมพ์ยาวขึ้น = ฟอนต์ย่อจนถึง floor แล้วค่อยขึ้นบรรทัดใหม่ กรอบสูงขึ้นตาม
|
||||
* stampLayout ทันที — blur/Enter = บันทึก (ข้อความว่าง = ยกเลิก), Escape = ยกเลิก ·
|
||||
* Enter ไม่ใช้ขึ้นบรรทัด (ตรายางจัดบรรทัดอัตโนมัติล้วน ให้ตรงกับ preview/ตอนวางเสมอ) */
|
||||
function StampEditor({
|
||||
size,
|
||||
data,
|
||||
enabled,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
size: Size;
|
||||
data: StampData;
|
||||
enabled: boolean;
|
||||
onSave: (label: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(data.label);
|
||||
// จบ editor ได้ครั้งเดียว — Enter ซ้ำจาก double-tap บนจอสัมผัส (ก่อน React ถอด
|
||||
// editor) หรือ blur ตามหลัง Enter ต้องเงียบ ไม่งั้น update เดียวกันกลายเป็น
|
||||
// 2 version ในประวัติ F19 (เหตุผลเดียวกับ doneRef ของ TextboxEditor)
|
||||
const doneRef = useRef(false);
|
||||
const finish = (fn: () => void) => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
fn();
|
||||
};
|
||||
const w = toPx(data.scale, size.width);
|
||||
const c = data.color ?? STAMP_COLOR;
|
||||
const lay = useMemo(() => stampLayout(label, w, measureStampText), [label, w]);
|
||||
const commit = () =>
|
||||
finish(() => {
|
||||
const t = label.trim();
|
||||
if (t) onSave(t);
|
||||
else onCancel();
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="absolute flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
left: toPx(data.position.x, size.width),
|
||||
top: toPx(data.position.y, size.height),
|
||||
width: w,
|
||||
height: stampBoxHeight(lay, w),
|
||||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||||
borderRadius: Math.max(w * 0.04, 2),
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
aria-label="แก้ไขข้อความตรายาง"
|
||||
value={label}
|
||||
maxLength={STAMP_MAX_LABEL_CHARS}
|
||||
rows={lay.lines.length}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setLabel(e.target.value.replace(/\n/g, ""))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation(); // ไม่ให้ปุ่มในช่องข้อความชนคีย์ลัดของ viewer
|
||||
// Enter ระหว่าง IME compose = ยืนยันคำ ไม่ใช่บันทึก (เหตุผลเดียวกับ TextboxEditor)
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
finish(onCancel);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
// ไม่ใส่ padding แนวนอน: พื้นที่พิมพ์ = w − border (≈0.96w) กว้างกว่า maxW
|
||||
// ของ stampLayout (0.9w) เสมอ ข้อความที่ย่อจนพอดี 0.9w เป๊ะ จึงไม่ wrap ใน
|
||||
// editor ทั้งที่ผลหลังบันทึกเป็นบรรทัดเดียว (WYSIWYG — code review 2026-09-28)
|
||||
className="w-full resize-none bg-transparent text-center font-bold focus:outline-none"
|
||||
style={{
|
||||
color: c,
|
||||
fontFamily: STAMP_FONT_STACK,
|
||||
fontSize: lay.fontSize,
|
||||
lineHeight: lay.lineHeight,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ตรายางหนึ่งดวง — stampLayout (canvas measure ทีละบรรทัดหลายรอบตามจำนวนบรรทัด
|
||||
* ที่ลอง wrap) ถูก useMemo ไว้ dep primitive [label, w] จึงคำนวณใหม่เฉพาะเมื่อ
|
||||
* label/ความกว้างจริงเปลี่ยน ไม่ตาม re-render เพราะเหตุอื่น (annotation ตัวอื่น
|
||||
* เพิ่ม/ลบ, selectedId, SSE event) — memo ระดับ component ไม่ช่วยเพราะ props
|
||||
* (s/size object) เป็น reference ใหม่ทุก render ของ parent (code review
|
||||
* 2026-09-28) · ฟอนต์ที่วัดเป็น system-ui (STAMP_FONT_STACK) มีพร้อมตั้งแต่
|
||||
* browser เปิด จึงไม่ต้องรอ document.fonts.ready แล้ววัดซ้ำ (review ข้อ 7) */
|
||||
function StampItem({
|
||||
s,
|
||||
size,
|
||||
enabled,
|
||||
onSelect,
|
||||
}: {
|
||||
s: InkStamp;
|
||||
size: Size;
|
||||
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events/โฟกัสของทุกรายการ */
|
||||
enabled: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const w = toPx(s.data.scale, size.width);
|
||||
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
|
||||
// จัดวางข้อความแบบเดียวกับ preview/export: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ
|
||||
// ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (จนถึง floor 10%), คำยาวจริงเกิน floor ค่อย wrap
|
||||
// ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด (2026-09-28 รอบสอง — ตาม stampLayout)
|
||||
const lay = useMemo(
|
||||
() => stampLayout(s.data.label, w, measureStampText),
|
||||
[s.data.label, w],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={enabled ? 0 : -1}
|
||||
aria-label={`ตรายาง: ${s.data.label}`}
|
||||
className="absolute flex flex-col items-center justify-center overflow-hidden text-center font-bold"
|
||||
style={{
|
||||
left: toPx(s.data.position.x, size.width),
|
||||
top: toPx(s.data.position.y, size.height),
|
||||
width: w,
|
||||
height: stampBoxHeight(lay, w),
|
||||
// มุมเอียง (−180..180) หมุนรอบ**กลางกรอบ** — left/top ยังเป็นมุมซ้ายบนของ
|
||||
// กรอบ*ก่อนหมุน*เสมอ (bbox = AABB ของกรอบหมุน คำนวณใน geometry)
|
||||
...(s.data.rotation
|
||||
? { transform: `rotate(${s.data.rotation}deg)`, transformOrigin: "center" }
|
||||
: null),
|
||||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||||
borderRadius: Math.max(w * 0.04, 2),
|
||||
color: c,
|
||||
fontFamily: STAMP_FONT_STACK,
|
||||
fontSize: lay.fontSize,
|
||||
lineHeight: lay.lineHeight,
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (enabled) onSelect(s.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!enabled) return;
|
||||
// กลืนเฉพาะคีย์ที่เราจัดการเอง — คีย์อื่น (DEL/Escape/H/D/C/B ของ viewer
|
||||
// ที่ฟังที่ window) ต้อง bubble ต่อ เหตุผลเดียวกับ div กล่องข้อความด้านบน
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(s.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{lay.lines.map((ln, i) => (
|
||||
// บรรทัดว่างจาก wrap ใช้ nbsp กัน div ยุบ — ตัวอักษรใน quotes เป็น U+00A0 จริง ๆ
|
||||
// (มองเหมือน space ปกติ — ห้ามลบหรือแทนด้วย space)
|
||||
<div key={i}>{ln || " "}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TextboxStampLayer({
|
||||
size,
|
||||
textboxes,
|
||||
|
|
@ -275,6 +432,10 @@ export default function TextboxStampLayer({
|
|||
<div
|
||||
key={t.id}
|
||||
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
|
||||
// a11y: กล่องคลิกเลือกได้ = ปุ่ม — ใช้คีย์บอร์ด (Tab/Enter/Space) เลือกได้ด้วย
|
||||
role="button"
|
||||
tabIndex={enabled ? 0 : -1}
|
||||
aria-label={`กล่องข้อความ${t.data.text ? `: ${t.data.text.slice(0, 40)}` : ""}`}
|
||||
className="absolute whitespace-pre-wrap break-words"
|
||||
style={{
|
||||
left: toPx(t.data.position.x, size.width),
|
||||
|
|
@ -293,11 +454,22 @@ export default function TextboxStampLayer({
|
|||
pointerEvents: enabled ? "auto" : "none",
|
||||
cursor: enabled ? "text" : "default",
|
||||
}}
|
||||
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (enabled) onSelect(t.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!enabled) return;
|
||||
// กลืนเฉพาะคีย์ที่เราจัดการเอง (Enter/Space เลือกรายการ) — คีย์อื่น
|
||||
// (DEL/Backspace ลบ, Escape deselect, H/D/C/B ของ viewer ที่ฟังที่
|
||||
// window) ต้อง bubble ต่อ: focus ค้างบน item หลังคลิกเลือก (tabIndex)
|
||||
// แล้วกลืนทุกคีย์ = ปุ่มลบ/ยกเลิกตายหมด (code review 2026-09-28)
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(t.id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (enabled && t.userId === ownUserId) onStartEdit(t.id);
|
||||
|
|
@ -329,39 +501,24 @@ export default function TextboxStampLayer({
|
|||
onCancel={onCancelComposer}
|
||||
/>
|
||||
)}
|
||||
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
|
||||
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
|
||||
{stamps.map((s) => {
|
||||
const w = toPx(s.data.scale, size.width);
|
||||
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
|
||||
return (
|
||||
<div
|
||||
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง กว้าง = scale% ของหน้า สูง 0.4 เท่า
|
||||
(§5): คำสั้นอยู่บรรทัดเดียว คำยาว wrap ≤3 บรรทัด (2026-09-28) — ตัว render
|
||||
อยู่ใน StampItem (memo), โหมดแก้ข้อความ (ปุ่ม "แก้ไขข้อความ" ในแผง selection)
|
||||
= StampEditor แทนกรอบเดิม */}
|
||||
{stamps.map((s) =>
|
||||
editingId === s.id ? (
|
||||
<StampEditor
|
||||
key={s.id}
|
||||
className="absolute flex items-center justify-center overflow-hidden text-center font-bold"
|
||||
style={{
|
||||
left: toPx(s.data.position.x, size.width),
|
||||
top: toPx(s.data.position.y, size.height),
|
||||
width: w,
|
||||
height: w * STAMP_HEIGHT_RATIO,
|
||||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||||
borderRadius: Math.max(w * 0.04, 2),
|
||||
color: c,
|
||||
fontFamily: STAMP_FONT_STACK,
|
||||
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (enabled) onSelect(s.id);
|
||||
}}
|
||||
>
|
||||
{s.data.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
size={size}
|
||||
data={s.data}
|
||||
enabled={enabled}
|
||||
onSave={(label) => onCommitEdit(s.id, label)}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
) : (
|
||||
<StampItem key={s.id} s={s} size={size} enabled={enabled} onSelect={onSelect} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
108
components/pdf/toolbar-tools.ts
Normal file
108
components/pdf/toolbar-tools.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// pure module ของแถบ tool หน้า annotate — ย้ายจาก PDFViewer.tsx แบบ
|
||||
// behavior-preserving (PLAN-TOOLBAR.md ขั้น 1) ไม่มี React/state เพื่อ unit
|
||||
// test ได้ (tests/components/toolbar-tools.test.ts)
|
||||
import {
|
||||
Eraser,
|
||||
EyeOff,
|
||||
Hand,
|
||||
Highlighter,
|
||||
MessageSquarePlus,
|
||||
MousePointer2,
|
||||
Pencil,
|
||||
Shapes,
|
||||
Stamp,
|
||||
Type,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type Tool =
|
||||
| "select"
|
||||
| "pan"
|
||||
| "highlight"
|
||||
| "draw"
|
||||
| "comment"
|
||||
| "shape"
|
||||
| "erase"
|
||||
| "redact"
|
||||
| "textbox"
|
||||
| "stamp";
|
||||
|
||||
export type ToolEntry = { key: Tool; label: string; text: string; Icon: LucideIcon };
|
||||
|
||||
/** label = ชื่อเต็มสำหรับ title/aria-label (พร้อมคีย์ลัด/คำอังกฤษประกอบ) —
|
||||
* text = คำสั้น ๆ แสดงเป็นข้อความหลังไอคอนบนปุ่ม (คำขอผู้ใช้ 2026-09-21: ผู้ใช้ศาล
|
||||
* ไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span ชื่อ
|
||||
* tool-active ของ H3 ที่ถอดออก) — สำหรับปุ่ม dropdown ที่มีตัวเลือกย่อย (มาร์ก
|
||||
* ข้อความ/วาดเส้น/รูปทรง/ตรายาง) เคยแสดงชื่อตัวเลือกล่าสุดแทน text ตลอด (คำขอผู้ใช้
|
||||
* 2026-09-23) แต่ทำให้ชื่อ tool หายไปจากแถบ ("วาดเส้น" เห็นเป็น "ปากกา" ตั้งแต่
|
||||
* ยังไม่ได้เลือกอะไร) — 2026-09-29 จึงแสดงค่าล่าสุด**เฉพาะตอน tool active**
|
||||
* (ค่าที่จะใช้จริงเมื่อวาด) ไม่ active กลับมาแสดง text ชื่อ tool เหมือนปุ่มอื่น */
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
{ key: "select", label: "เลือก", text: "เลือก", Icon: MousePointer2 },
|
||||
{ key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand },
|
||||
{ key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter },
|
||||
{ key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil },
|
||||
{ key: "comment", label: "คอมเมนต์ (C)", text: "คอมเมนต์", Icon: MessageSquarePlus },
|
||||
{ key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes },
|
||||
{ key: "erase", label: "ยางลบ (เส้น/รูปทรง/ตรายางของตัวเอง)", text: "ยางลบ", Icon: Eraser },
|
||||
{ key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff },
|
||||
{ key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type },
|
||||
{ key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp },
|
||||
];
|
||||
|
||||
/** N3 ไฟล์ภาพ + PDF สแกนล้วน: ไม่มีข้อความให้คลุม — ซ่อน tool มาร์กข้อความ
|
||||
* (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบ (hideHighlightTool ของ PDFViewer) */
|
||||
export function visibleToolsFor(hideHighlight: boolean): ToolEntry[] {
|
||||
return hideHighlight ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
|
||||
}
|
||||
|
||||
/** ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
|
||||
* ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันที่ switchTool ให้จบ (สลับ tool
|
||||
* อื่นไม่กระทบ) */
|
||||
export function canSwitchTo(t: Tool, hideHighlight: boolean): boolean {
|
||||
return !(t === "highlight" && hideHighlight);
|
||||
}
|
||||
|
||||
/** หน้าตาปุ่ม tool dropdown ที่มีค่าย่อย (มาร์กข้อความ/วาดเส้น/รูปทรง/ตรายาง) —
|
||||
* ไม่ active → text ชื่อ tool + title = label เหมือนปุ่มเดี่ยวอื่น;
|
||||
* active + มีค่า (ค่าที่จะใช้จริงเมื่อวาด เช่น ชื่อปากกา/preset ตรายาง "กำหนดเอง")
|
||||
* → text = ค่า + title = "label — ค่า" ตาม pattern เดิม;
|
||||
* ไม่ส่งค่า (textbox ไม่มีค่าย่อย) → ชื่อ tool แม้ active */
|
||||
export function toolButtonFace(
|
||||
active: boolean,
|
||||
tool: Pick<ToolEntry, "label" | "text">,
|
||||
value?: string | null,
|
||||
): { text: string; title: string } {
|
||||
const showValue = active && value != null;
|
||||
return {
|
||||
text: showValue ? value : tool.text,
|
||||
title: showValue ? `${tool.label} — ${value}` : tool.label,
|
||||
};
|
||||
}
|
||||
|
||||
/** จำ intent เปิด/พับเมนูของปุ่ม dropdown ก่อนเรียก switchTool (ซึ่งปิดเมนูทุกตัว):
|
||||
* กดปุ่มจาก tool อื่น = เปิดเมนูของ tool ที่เพิ่ง active, tool เดิม+เมนูปิด = เปิด,
|
||||
* tool เดิม+เมนูเปิด = พับ (toggle) */
|
||||
export function menuIntentAfterClick(current: Tool, key: Tool, menuOpen: boolean): boolean {
|
||||
return current !== key || !menuOpen;
|
||||
}
|
||||
|
||||
/** tool ที่ปุ่มเป็น dropdown มีเมนูของตัวเอง — ทุกจุดที่ "ปิดเมนูทั้งชุด" (switchTool/
|
||||
* outside-click/resize/auto-select/เปิด popup bookmark/reset tool ตรง) ต้องวนจากชุด
|
||||
* ค่านี้จุดเดียว: แต่ก่อน enumerate setter ด้วยมือ 6 แห่ง จนเมนูใหม่/พฤติกรรมใหม่ลืม
|
||||
* แห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม active หาปุ่มไม่เจอเงียบ ๆ
|
||||
* ตั้งแต่ 2026-09-23) — ฝั่ง PDFViewer ผูก setter/ref เป็น Record<MenuTool,…> ให้
|
||||
* ลืมแถว = compile error, ฝั่งนี้ test cross-check กับ TOOLS */
|
||||
export const TOOL_MENU_KEYS = ["highlight", "draw", "shape", "textbox", "stamp"] as const satisfies readonly Tool[];
|
||||
export type MenuTool = (typeof TOOL_MENU_KEYS)[number];
|
||||
|
||||
/** คีย์ลัดสลับ tool (F3) — จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่มตาม layout
|
||||
* สหรัฐ) ไม่ใช่ `e.key` (ตัวอักษรที่ layout ปัจจุบันจะพิมพ์): ผู้ใช้ศาลส่วนใหญ่สลับ
|
||||
* keyboard เป็นภาษาไทย ซึ่งปุ่มเดียวกันให้ e.key = "ภ" ไม่ใช่ "h" — เช็ค e.key
|
||||
* ทำให้คีย์ลัดทั้งชุดใช้ไม่ได้เลยบน layout ไทย (PLAN-TOOLBAR.md finding #1,
|
||||
* 2026-09-29) */
|
||||
export const TOOL_SHORTCUTS: Readonly<Record<string, Tool>> = {
|
||||
KeyH: "highlight",
|
||||
KeyD: "draw",
|
||||
KeyC: "comment",
|
||||
};
|
||||
|
|
@ -165,7 +165,7 @@ function PageSections({
|
|||
{pageSummaryLine(pageItems)}
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${fileId}?page=${pi + 1}`}
|
||||
href={`/note/annotate/${fileId}?page=${pi + 1}`}
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
|
||||
>
|
||||
เปิดหน้านี้
|
||||
|
|
@ -183,8 +183,8 @@ function PageSections({
|
|||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||
const href = isBm
|
||||
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${fileId}?ann=${item.dto.id}`;
|
||||
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||
: `/note/annotate/${fileId}?ann=${item.dto.id}`;
|
||||
return (
|
||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
|
||||
|
|
|
|||
91
components/utils/DependentDropdown.tsx
Normal file
91
components/utils/DependentDropdown.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use client';
|
||||
|
||||
import { useState, ChangeEvent, useEffect } from 'react';
|
||||
|
||||
export interface DropdownData {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
interface DependentDropdownProps {
|
||||
data: DropdownData;
|
||||
name1: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||
name2: string; // เพิ่มมาเพื่อใช้กับ Form Action
|
||||
label1?: string;
|
||||
label2?: string;
|
||||
placeholder1?: string;
|
||||
placeholder2?: string;
|
||||
onSelectChange?: (mainValue: string, subValue: string) => void;
|
||||
}
|
||||
|
||||
export default function DependentDropdown({
|
||||
data,
|
||||
name1,
|
||||
name2,
|
||||
label1 = 'เลือกตัวเลือกหลัก:',
|
||||
label2 = 'เลือกตัวเลือกย่อย:',
|
||||
placeholder1 = '-- กรุณาเลือก --',
|
||||
placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --',
|
||||
onSelectChange,
|
||||
}: DependentDropdownProps) {
|
||||
const [mainSelection, setMainSelection] = useState<string>('');
|
||||
const [subSelection, setSubSelection] = useState<string>('');
|
||||
|
||||
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setMainSelection(value);
|
||||
setSubSelection('');
|
||||
};
|
||||
|
||||
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setSubSelection(e.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectChange) {
|
||||
onSelectChange(mainSelection, subSelection);
|
||||
}
|
||||
}, [mainSelection, subSelection, onSelectChange]);
|
||||
|
||||
const subOptions = mainSelection ? data[mainSelection] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{/* Dropdown ที่ 1 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label1}</label>
|
||||
<select
|
||||
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={mainSelection}
|
||||
onChange={handleMainChange}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
|
||||
>
|
||||
<option value="">{placeholder1}</option>
|
||||
{Object.keys(data).map((key) => (
|
||||
<option key={key} value={key}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Dropdown ที่ 2 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label2}</label>
|
||||
<select
|
||||
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={subSelection}
|
||||
onChange={handleSubChange}
|
||||
disabled={!mainSelection}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
|
||||
>
|
||||
<option value="">
|
||||
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
|
||||
</option>
|
||||
{subOptions.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
45
components/wordaddin/MessageBar.tsx
Normal file
45
components/wordaddin/MessageBar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
|
||||
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
|
||||
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
|
||||
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
|
||||
* แถบกว้างเท่ากันทั้งคอลัมน์
|
||||
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
|
||||
*/
|
||||
|
||||
const ICONS = {
|
||||
error: CircleAlert,
|
||||
warning: TriangleAlert,
|
||||
success: CircleCheck,
|
||||
} as const;
|
||||
|
||||
export function MessageBar({
|
||||
intent,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
intent: "error" | "warning" | "success";
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const Icon = ICONS[intent];
|
||||
const tone =
|
||||
intent === "error"
|
||||
? "border-error/30 bg-error/10 text-error-ink"
|
||||
: intent === "warning"
|
||||
? "border-warning/30 bg-warning/15 text-warning-ink"
|
||||
: "border-success/30 bg-success/10 text-success-ink";
|
||||
return (
|
||||
<div
|
||||
role={intent === "error" ? "alert" : "status"}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1 break-words">{children}</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
components/wordaddin/SaveControls.tsx
Normal file
65
components/wordaddin/SaveControls.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { CircleCheck, Save, X } from "lucide-react";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
|
||||
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||
*/
|
||||
export function SaveControls({
|
||||
disabled,
|
||||
busy,
|
||||
status,
|
||||
error,
|
||||
closeSupported,
|
||||
onSave,
|
||||
onSaveAndClose,
|
||||
}: {
|
||||
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
status: "saving" | "saved" | null;
|
||||
error: string | null;
|
||||
closeSupported: boolean;
|
||||
onSave: () => void;
|
||||
onSaveAndClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
<Save className="size-4" aria-hidden />
|
||||
บันทึก
|
||||
</button>
|
||||
{closeSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
disabled={disabled || busy}
|
||||
onClick={onSaveAndClose}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
บันทึกและปิดเอกสาร
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
|
||||
{!busy && status === "saved" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
|
||||
<CircleCheck className="size-4 text-success-ink" aria-hidden />
|
||||
บันทึกแล้ว
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveControls;
|
||||
13
components/wordaddin/Spinner.tsx
Normal file
13
components/wordaddin/Spinner.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
|
||||
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
|
||||
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
|
||||
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||
{label ? <span>{label}</span> : null}
|
||||
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
616
components/wordaddin/TaskpaneApp.tsx
Normal file
616
components/wordaddin/TaskpaneApp.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
/* global Office */
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileUp, GitCommitHorizontal } from "lucide-react";
|
||||
|
||||
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
|
||||
import * as authService from "@/wordaddin/services/authService";
|
||||
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
|
||||
import * as documentService from "@/wordaddin/services/documentService";
|
||||
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||
import * as metadataService from "@/wordaddin/services/metadataService";
|
||||
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
|
||||
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
|
||||
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||
|
||||
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import SaveControls from "./SaveControls";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TemplateToolsPanel from "./TemplateToolsPanel";
|
||||
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||
|
||||
/**
|
||||
* Task pane หลัก — state machine:
|
||||
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
|
||||
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
|
||||
* → (ticket ผ่าน + tracked document) tracking
|
||||
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
|
||||
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
|
||||
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
|
||||
* ตาม fid — PLAN-ADDIN-AUTH.md)
|
||||
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
|
||||
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
|
||||
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
||||
*
|
||||
* กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||
*
|
||||
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
|
||||
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
|
||||
*/
|
||||
|
||||
type Phase = "checking" | "no-access" | "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";
|
||||
|
||||
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
|
||||
function demoVersions(now: number): VersionRecord[] {
|
||||
return [
|
||||
{
|
||||
versionId: "demo-v3",
|
||||
versionNumber: 3,
|
||||
createdAt: new Date(now - 3_600_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: "ปรับคำร้องตามคำแนะนำอัยการ",
|
||||
hasPdf: true,
|
||||
fields: null,
|
||||
},
|
||||
{
|
||||
versionId: "demo-v2",
|
||||
versionNumber: 2,
|
||||
createdAt: new Date(now - 86_400_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: null,
|
||||
hasPdf: true,
|
||||
fields: null,
|
||||
},
|
||||
{
|
||||
versionId: "demo-v1",
|
||||
versionNumber: 1,
|
||||
createdAt: new Date(now - 172_800_000).toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "tester01",
|
||||
contentHash: "demo",
|
||||
message: "สร้างเอกสารครั้งแรก",
|
||||
hasPdf: false,
|
||||
fields: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
|
||||
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
|
||||
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
|
||||
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
|
||||
const [flowError, setFlowError] = useState<string | null>(null);
|
||||
const [flowWarning, setFlowWarning] = useState<string | null>(null);
|
||||
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
|
||||
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
|
||||
|
||||
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
|
||||
|
||||
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
|
||||
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
|
||||
const [closeSupported] = useState(() => documentService.canCloseDocument());
|
||||
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
|
||||
const [saveBusy, setSaveBusy] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
|
||||
const [hasUncommitted, setHasUncommitted] = useState(false);
|
||||
|
||||
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
|
||||
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
|
||||
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
||||
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
setPhase("no-access");
|
||||
setActiveTab("versioning");
|
||||
setDocumentId(null);
|
||||
setDocumentTemplateId(null);
|
||||
setVersions([]);
|
||||
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const refreshHistory = useCallback(
|
||||
async (id: string) => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
setVersions(await getVersions(id));
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
[handleAuthRequired],
|
||||
);
|
||||
|
||||
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
|
||||
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
||||
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
|
||||
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
|
||||
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
||||
const detect = useCallback(async () => {
|
||||
setPhase("checking");
|
||||
setBootError(null);
|
||||
try {
|
||||
let properties: metadataService.TrackedDocumentProperties | null = null;
|
||||
try {
|
||||
properties = await metadataService.readTrackedProperties();
|
||||
} catch {
|
||||
properties = null;
|
||||
}
|
||||
if (!properties) {
|
||||
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
const signIn = await authService.signInWithTicket({
|
||||
ticketData: properties.ticketData,
|
||||
ticketHmac: properties.ticketHmac,
|
||||
});
|
||||
if (!signIn.ok) {
|
||||
setNoAccessNotice(signIn.message);
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
setUsername(authService.getUsername() ?? null);
|
||||
|
||||
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||
const id = tracked ? properties.documentTrackingId : undefined;
|
||||
if (!id) {
|
||||
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
|
||||
setDocumentTemplateId(properties.templateId ?? null);
|
||||
setPhase("untracked");
|
||||
return;
|
||||
}
|
||||
setDocumentId(id);
|
||||
setDocumentTemplateId(null);
|
||||
setPhase("tracking");
|
||||
await refreshHistory(id);
|
||||
} catch (error) {
|
||||
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
}, [refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
|
||||
void detect();
|
||||
}, [demo, detect]);
|
||||
|
||||
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
|
||||
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
|
||||
useEffect(() => {
|
||||
autoSaveService.onError = (error) =>
|
||||
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
|
||||
autoSaveService.onSaved = () => setAutoSaveError(null);
|
||||
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
|
||||
if (!demo) {
|
||||
autoSaveService.start();
|
||||
}
|
||||
return () => {
|
||||
autoSaveService.stop();
|
||||
autoSaveService.onError = undefined;
|
||||
autoSaveService.onSaved = undefined;
|
||||
autoSaveService.onStateChange = undefined;
|
||||
};
|
||||
}, [demo]);
|
||||
|
||||
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
|
||||
const handleSaveOnly = async () => {
|
||||
setSaveBusy(true);
|
||||
setSaveStatus("saving");
|
||||
setSaveError(null);
|
||||
setAutoSaveError(null);
|
||||
try {
|
||||
await autoSaveService.save();
|
||||
setSaveStatus("saved");
|
||||
} catch (error) {
|
||||
setSaveStatus(null);
|
||||
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
|
||||
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
|
||||
const handleSaveAndClose = async () => {
|
||||
setSaveBusy(true);
|
||||
setSaveStatus("saving");
|
||||
setSaveError(null);
|
||||
setAutoSaveError(null);
|
||||
try {
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
await autoSaveService.forceSave();
|
||||
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveStatus(null);
|
||||
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
|
||||
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
|
||||
const handleStartVersioning = async (message: string) => {
|
||||
setStarting(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
setFlowWarning(null);
|
||||
try {
|
||||
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
|
||||
setDocumentId(result.documentId);
|
||||
setDocumentTemplateId(null);
|
||||
setPhase("tracking");
|
||||
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||
if (!result.hasPdf)
|
||||
setFlowWarning(
|
||||
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||
);
|
||||
await refreshHistory(result.documentId);
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
|
||||
* message คือ commit message จาก dialog (FR-13)
|
||||
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
|
||||
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
setFlowWarning(null);
|
||||
try {
|
||||
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
|
||||
if (closeAfter) {
|
||||
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
|
||||
return;
|
||||
}
|
||||
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||
if (!result.hasPdf)
|
||||
setFlowWarning(
|
||||
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||
);
|
||||
await refreshHistory(documentId);
|
||||
} catch (error) {
|
||||
if (error instanceof VersionSavedButCloseFailedError) {
|
||||
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
|
||||
setFlowWarning(error.message);
|
||||
await refreshHistory(documentId);
|
||||
return;
|
||||
}
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
|
||||
*/
|
||||
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
|
||||
setDownloading(version.versionId);
|
||||
setFlowError(null);
|
||||
try {
|
||||
const link = await getDownloadLink(documentId, version.versionId, format);
|
||||
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||
Office.context.ui.openBrowserWindow(link.url);
|
||||
setFlowSuccess(
|
||||
format === "pdf"
|
||||
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
|
||||
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
|
||||
{phase !== "no-access" && phase !== "checking" && username ? (
|
||||
/* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
|
||||
จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
|
||||
<span
|
||||
className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
|
||||
title={username}
|
||||
>
|
||||
{username}
|
||||
</span>
|
||||
) : 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/no-access ยังไม่มีอะไรให้จัดการ)
|
||||
ปิดทั้งสองปุ่มระหว่าง 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="กำลังตรวจสอบเอกสาร…" />
|
||||
{bootError ? (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => void detect()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{bootError}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "no-access" ? (
|
||||
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||
</p>
|
||||
{documentTemplateId ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||
และ sync ค่า fields ให้อัตโนมัติ
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||
disabled={starting}
|
||||
onClick={() => setStartConfirmOpen(true)}
|
||||
>
|
||||
<FileUp className="size-4" aria-hidden />
|
||||
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||
</button>
|
||||
<SaveControls
|
||||
disabled={starting}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "tracking" && activeTab === "versioning" ? (
|
||||
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{/* กลุ่มข้อความสถานะ */}
|
||||
{hasUncommitted ? (
|
||||
<MessageBar intent="warning">
|
||||
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
|
||||
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
|
||||
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||
|
||||
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
|
||||
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
|
||||
disabled={saving}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<GitCommitHorizontal className="size-4" aria-hidden />
|
||||
Save & Version
|
||||
</button>
|
||||
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||
<SaveControls
|
||||
disabled={saving}
|
||||
busy={saveBusy}
|
||||
status={saveStatus}
|
||||
error={saveError}
|
||||
closeSupported={closeSupported}
|
||||
onSave={() => void handleSaveOnly()}
|
||||
onSaveAndClose={() => void handleSaveAndClose()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
|
||||
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
|
||||
|
||||
<VersionHistoryPanel
|
||||
versions={versions}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
downloadingVersionId={downloadingVersionId}
|
||||
downloadingPdfVersionId={downloadingPdfVersionId}
|
||||
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
|
||||
onDownload={(version) => void handleDownload(version)}
|
||||
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
|
||||
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
|
||||
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
|
||||
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
|
||||
) : 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) */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskpaneApp;
|
||||
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;
|
||||
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Download, FileText, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
/**
|
||||
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
|
||||
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
|
||||
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
|
||||
*/
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime())
|
||||
? iso
|
||||
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
|
||||
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";
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
versions,
|
||||
loading,
|
||||
error,
|
||||
downloadingVersionId,
|
||||
downloadingPdfVersionId,
|
||||
onDownloadPdf,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
versions: VersionRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||
downloadingVersionId: string | null;
|
||||
downloadingPdfVersionId: string | null;
|
||||
onDownloadPdf: (version: VersionRecord) => void;
|
||||
onDownload: (version: VersionRecord) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="mt-4 mb-2 flex items-baseline justify-between">
|
||||
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
|
||||
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
|
||||
</div>
|
||||
|
||||
{loading ? <Spinner label="กำลังโหลด…" /> : null}
|
||||
|
||||
{error ? (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={onRetry}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && versions.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
|
||||
) : null}
|
||||
|
||||
{versions.length > 0 ? (
|
||||
<ol className="relative">
|
||||
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
|
||||
{versions.length > 1 ? (
|
||||
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
|
||||
) : null}
|
||||
{versions.map((version, index) => {
|
||||
const isLatest = index === 0;
|
||||
return (
|
||||
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
|
||||
{isLatest ? (
|
||||
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingVersionId === version.versionId}
|
||||
onClick={() => onDownload(version)}
|
||||
>
|
||||
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
|
||||
{downloadingVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
)}
|
||||
ดาวน์โหลด
|
||||
</button>
|
||||
{version.hasPdf ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_ACTION}
|
||||
disabled={anyDownloading}
|
||||
aria-busy={downloadingPdfVersionId === version.versionId}
|
||||
onClick={() => onDownloadPdf(version)}
|
||||
>
|
||||
{downloadingPdfVersionId === version.versionId ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
)}
|
||||
PDF
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{version.message ? (
|
||||
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
|
||||
{version.message}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-xs text-ink-soft">
|
||||
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionHistoryPanel;
|
||||
131
components/wordaddin/WordAddinGate.tsx
Normal file
131
components/wordaddin/WordAddinGate.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { signInForDemo } from "@/wordaddin/services/authService";
|
||||
|
||||
import { MessageBar } from "./MessageBar";
|
||||
import { Spinner } from "./Spinner";
|
||||
import TaskpaneApp from "./TaskpaneApp";
|
||||
|
||||
/**
|
||||
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
|
||||
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
|
||||
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
|
||||
* Office ที่ module top-level จึงปลอดภัย)
|
||||
*
|
||||
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
|
||||
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
|
||||
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
|
||||
*/
|
||||
|
||||
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
|
||||
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
|
||||
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
|
||||
const ONREADY_FALLBACK_MS = 5_000;
|
||||
|
||||
/**
|
||||
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
|
||||
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
|
||||
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
|
||||
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
|
||||
*/
|
||||
let officeReadyPromise: Promise<boolean> | null = null;
|
||||
|
||||
function ensureOfficeJs(): Promise<boolean> {
|
||||
if (!officeReadyPromise) {
|
||||
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||
if (typeof Office !== "undefined") {
|
||||
void Office.onReady(() => resolve(true));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||
const onReady = () => {
|
||||
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||
void Office.onReady(() => {
|
||||
clearTimeout(fallbackTimer);
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
script.onload = onReady;
|
||||
script.onerror = () => {
|
||||
clearTimeout(fallbackTimer);
|
||||
officeReadyPromise = null;
|
||||
script.remove();
|
||||
resolve(false);
|
||||
};
|
||||
script.src = OFFICE_JS_SRC;
|
||||
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
|
||||
script.id = "office-js";
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
return officeReadyPromise;
|
||||
}
|
||||
|
||||
type GateStatus = "loading" | "ready" | "error";
|
||||
|
||||
export function WordAddinGate() {
|
||||
const [status, setStatus] = useState<GateStatus>("loading");
|
||||
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
|
||||
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
|
||||
signInForDemo();
|
||||
setDemo(demoParam);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void ensureOfficeJs()
|
||||
.then((ok) => {
|
||||
if (!cancelled) setStatus(ok ? "ready" : "error");
|
||||
})
|
||||
.catch(() => {
|
||||
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<MessageBar
|
||||
intent="error"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
ลองใหม่
|
||||
</button>
|
||||
}
|
||||
>
|
||||
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
|
||||
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TaskpaneApp demo={demo ?? undefined} />;
|
||||
}
|
||||
|
||||
export default WordAddinGate;
|
||||
105
db/schema.ts
105
db/schema.ts
|
|
@ -111,7 +111,16 @@ export type TextboxData = {
|
|||
|
||||
/** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง)
|
||||
* color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */
|
||||
export type StampData = { label: string; position: Point; scale: number; color?: string; note?: string };
|
||||
/** rotation = มุมเอียงของตรายางเป็นองศา (−180..180, หมุนรอบ**กลางกรอบ** — ลบ =
|
||||
* ทวนเข็มนาฬิกา) ไม่มี field = ไม่เอียง (0°) — แถวเดิมไม่มี ใช้ร่วมกันได้ */
|
||||
export type StampData = {
|
||||
label: string;
|
||||
position: Point;
|
||||
scale: number;
|
||||
color?: string;
|
||||
note?: string;
|
||||
rotation?: number;
|
||||
};
|
||||
|
||||
/** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */
|
||||
export type AnnotationData =
|
||||
|
|
@ -244,12 +253,76 @@ export const annotationVersions = pgTable(
|
|||
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
|
||||
);
|
||||
|
||||
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
|
||||
|
||||
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
|
||||
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
|
||||
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
|
||||
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
|
||||
docId: text("doc_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
|
||||
);
|
||||
|
||||
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
|
||||
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
|
||||
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
|
||||
export const documentVersions = pgTable(
|
||||
"document_versions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
documentId: uuid("document_id")
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: "cascade" }),
|
||||
versionNumber: integer("version_number").notNull(),
|
||||
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
|
||||
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
|
||||
clientVersionId: text("client_version_id"),
|
||||
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
|
||||
contentHash: text("content_hash").notNull(),
|
||||
/** commit message (FR-13) — trim แล้วว่าง = NULL */
|
||||
message: text("message"),
|
||||
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls
|
||||
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */
|
||||
fields: jsonb("fields").$type<Record<string, string>>(),
|
||||
templateId: text("template_id"),
|
||||
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
|
||||
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
|
||||
fileName: text("file_name"),
|
||||
docxObjectKey: text("docx_object_key").notNull(),
|
||||
pdfObjectKey: text("pdf_object_key"),
|
||||
docxSize: integer("docx_size").notNull(),
|
||||
pdfSize: integer("pdf_size"),
|
||||
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
|
||||
userId: text("user_id").notNull(),
|
||||
userName: text("user_name").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
|
||||
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
|
||||
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
|
||||
|
||||
export type AnnotationRow = typeof annotations.$inferSelect;
|
||||
export type BookmarkRow = typeof bookmarks.$inferSelect;
|
||||
export type ReplyRow = typeof annotationReplies.$inferSelect;
|
||||
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
|
||||
export type DocumentRow = typeof documents.$inferSelect;
|
||||
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
|
||||
|
||||
export type AnnotationDTO = {
|
||||
id: string;
|
||||
|
|
@ -362,3 +435,33 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
|
|||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
|
||||
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
|
||||
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
|
||||
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
|
||||
export type DocumentVersionDTO = {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
createdByUserName: string;
|
||||
contentHash: string;
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean;
|
||||
};
|
||||
|
||||
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
|
||||
return {
|
||||
versionId: row.id,
|
||||
versionNumber: row.versionNumber,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
createdByUserId: row.userId,
|
||||
createdByUserName: row.userName,
|
||||
contentHash: row.contentHash,
|
||||
fields: row.fields,
|
||||
message: row.message,
|
||||
hasPdf: row.pdfObjectKey !== null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
|
||||
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
|
||||
#
|
||||
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
|
||||
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
|
||||
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
|
||||
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: judes-note-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
# ชื่อ database ตรง DATABASE_URL ใน .env (สร้างตอน first init ของวอลุ่มเท่านั้น
|
||||
# — วอลุ่มเดิมจาก docker run ที่ init ด้วย postgres ต้องสร้างเอง:
|
||||
# docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"')
|
||||
POSTGRES_DB: ecourt-note
|
||||
ports:
|
||||
- "54322:5432"
|
||||
volumes:
|
||||
- judes-note-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
|
||||
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
|
||||
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
|
||||
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
|
||||
minio:
|
||||
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: judes-note-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
# โฮสต์ใช้ 19000/19001 เพราะ 9000/9001 ชนกับ container ของโปรเจกต์อื่นในเครื่องนี้
|
||||
# (ข้างใน container ยังเป็น 9000/9001 ตาม default ของ MinIO)
|
||||
- "19000:9000" # S3 API (MINIO_ENDPOINT/MINIO_PORT)
|
||||
- "19001:9001" # console ดู bucket/object ผ่าน browser
|
||||
volumes:
|
||||
- judes-note-minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
|
||||
judes-note-db-data:
|
||||
name: judes-note-db-data
|
||||
judes-note-minio-data:
|
||||
name: judes-note-minio-data
|
||||
|
|
@ -2,10 +2,11 @@
|
|||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# postinstall รัน scripts/patch-pdf-worker.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||
COPY scripts/patch-pdf-worker.mjs scripts/
|
||||
# postinstall เขียน public/pdf.worker.min.mjs — ต้องมีโฟลเดอร์ public/ ก่อน ไม่งั้น npm ci ล้ม
|
||||
RUN mkdir -p public && npm ci
|
||||
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
|
||||
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
|
||||
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||
RUN npm ci
|
||||
|
||||
# ─── Stage 2: build Next.js (output: standalone) ───
|
||||
FROM node:24-alpine AS builder
|
||||
|
|
@ -15,13 +16,27 @@ COPY . .
|
|||
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||
RUN node scripts/patch-pdf-worker.mjs
|
||||
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
|
||||
# + ดาวน์โหลด Office.js runtime ล่าสุด (public/wordaddin/office-js/) ของ taskpane wordaddin
|
||||
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
|
||||
ENV MINIO_ENDPOINT=127.0.0.1
|
||||
ENV MINIO_PORT=19000
|
||||
ENV MINIO_USE_SSL=false
|
||||
ENV MINIO_ACCESS_KEY=minioadmin
|
||||
ENV MINIO_SECRET_KEY=minioadmin
|
||||
ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54
|
||||
ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e
|
||||
ENV DOCVER_TOKEN_TTL_SECONDS=604800
|
||||
ENV MINIO_BUCKET=docver
|
||||
ENV DOCVER_LINK_TTL_SECONDS=900
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
|
@ -30,10 +45,14 @@ ENV NODE_ENV=production \
|
|||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||
# Next 14.2 รวม public/ (pdf.worker, ฟอนต์ Sarabun, samples PDF) ไว้ใน standalone แล้ว
|
||||
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
|
||||
# Next 14.2 standalone **ไม่รวม public/ และ .next/static เอง** — ต้องคัดลอกเองทั้งคู่
|
||||
# (ก่อนหน้านี้เข้าใจผิดว่า Next รวม public/ ให้: จริง ๆ มันหลุดเข้ามาแค่บางส่วนผ่าน output
|
||||
# file tracing ที่ตาม fs.readFile ของ server code (public/note จาก lib/server-pdf.ts,
|
||||
# ฟอนต์ Sarabun จาก export route) — public/wordaddin/office-js ไม่มีใครอ่านผ่าน fs
|
||||
# จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||
|
|
@ -43,10 +62,10 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules
|
|||
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
|
||||
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
|
||||
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
|
||||
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
||||
RUN mkdir -p public/note/uploads && chown nextjs:nodejs public/note/uploads
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
||||
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
|
||||
# seed-samples sync ตาราง files กับ public/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||
# seed-samples sync ตาราง files กับ public/note/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
| สิ่งที่ต้องมี | ค่า |
|
||||
|---|---|
|
||||
| URL | `https://<host>/docedit/note/api/external/documents` (แอป serve ใต้ basePath `/docedit/note`) |
|
||||
| URL | `https://<host>/docedit/api/note/external/documents` (API อยู่ใต้ `app/api/note/**` ของ basePath `/docedit` — ต่างจากหน้า UI ที่อยู่ใต้ `/docedit/note/…`) |
|
||||
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
|
||||
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
|
||||
| Content-Type | `application/json` |
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
|
||||
|
||||
```http
|
||||
POST /docedit/note/api/external/documents HTTP/1.1
|
||||
POST /docedit/api/note/external/documents HTTP/1.1
|
||||
Host: <host>
|
||||
x-api-key: <API Key ที่ได้รับ>
|
||||
content-type: application/json
|
||||
|
|
@ -191,7 +191,7 @@ error ทุกสถานะเป็น JSON รูปเดียวกั
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
Response (ตัดย่อ):
|
||||
|
|
@ -256,7 +256,7 @@ Response (ตัดย่อ):
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
|
||||
|
|
@ -266,7 +266,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
|
||||
|
|
@ -276,7 +276,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
|
||||
|
|
|
|||
214
docs/PLAN-ADDIN-AUTH.md
Normal file
214
docs/PLAN-ADDIN-AUTH.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`)
|
||||
|
||||
> วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md`
|
||||
|
||||
## 0. บริบท/เป้าหมาย
|
||||
|
||||
เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp
|
||||
`token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement:
|
||||
|
||||
- e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256`
|
||||
- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network)
|
||||
- **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage
|
||||
ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป
|
||||
- ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ —
|
||||
ไม่ต้องรองรับสองแบบพร้อมกัน)
|
||||
|
||||
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27)
|
||||
|
||||
| # | เรื่อง | ข้อตกลง |
|
||||
|---|---|---|
|
||||
| D1 | addin ยืนยันกับ `/token` อย่างไร | **ขยาย `POST /api/documents/token` รับ ticket เพิ่ม** — body `{ticket_data, HmacSha256}` server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า `x-api-key` เดิม (e-Court server-to-server) ใช้ต่อคู่กัน |
|
||||
| D2 | โค้ดซ้ำสองฝั่ง | แชร์ส่วน pure ผ่าน **`utils/docver-ticket.ts`** (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก `lib/`") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = `node:crypto` |
|
||||
| D3 | client-side HMAC | ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET` — **ยอมรับว่าค่าใน bundle สกัดได้** จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify |
|
||||
| D4 | รูปแบบ HMAC | sign บน **string ดิบของ `ticket_data` ตามที่อ่านได้จาก property** (ห้าม trim/normalize) · digest เป็น **hex lowercase 64 ตัว** — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน) |
|
||||
| D5 | `tid` | = `template` ของ claims (`tid` optional → `template: null`) · map `uid→userid`, `uname→username`, `fid` ตรง ๆ |
|
||||
| D6 | token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด | เก็บ `exp` ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → **renew อัตโนมัติ** · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access |
|
||||
| D7 | localStorage | key เดียว `wordaddin-auth` เก็บ JSON `{v:1, userId, userName, tokens: {[fid]: {token, exp}}}` · เจอ user ใหม่ = **เคลียร์ทั้งก้อน** (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern `versioningService`) |
|
||||
| D8 | "disable Add-in ทั้งหมด" | = phase `no-access` ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ |
|
||||
| D9 | ช่วง mock (`NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง) | addin ยิงขอ token จริงที่ same-origin `withBasePath("/api/documents/token")` (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ |
|
||||
| D10 | เครื่องมือทดสอบ | มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (`scripts/make-ticket-docx.mjs`) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย |
|
||||
|
||||
## 2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp)
|
||||
|
||||
Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive):
|
||||
|
||||
```
|
||||
ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}
|
||||
HmacSha256 = <hex 64 ตัว lowercase> = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim)
|
||||
```
|
||||
|
||||
| field | type | เงื่อนไข |
|
||||
|---|---|---|
|
||||
| `exp` | int (unix วินาที) | บังคับ · `exp <= now` = หมดอายุ |
|
||||
| `fid` | string | บังคับ · trim 1–200 · เป็น `docId` ของทุก operation ของกลุ่ม docver |
|
||||
| `uid` | string | บังคับ · trim 1–100 |
|
||||
| `uname` | string | บังคับ · trim 1–200 |
|
||||
| `tid` | string | optional · ≤100 · template ต้นทาง (ไม่มี = `template: null`) |
|
||||
|
||||
- field อื่นปนมา = reject (allowlist ตาม convention ของ repo)
|
||||
- secret ปัจจุบัน = `"super_secret"` — **e-Court + `ADDIN_TICKET_SECRET` (server) +
|
||||
`NEXT_PUBLIC_ADDIN_TICKET_SECRET` (client) ต้องตรงกันทั้งสาม**
|
||||
- ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data`
|
||||
ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access)
|
||||
|
||||
## 3. ฝั่ง server — `POST /api/documents/token` รับ ticket เพิ่ม
|
||||
|
||||
Branch ตาม header (ตัวตั้ง = `x-api-key`):
|
||||
|
||||
| เคส | พฤติกรรม |
|
||||
|---|---|
|
||||
| มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) |
|
||||
| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` |
|
||||
| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) |
|
||||
| HMAC ผิด / parse ticket ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) |
|
||||
| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 |
|
||||
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
|
||||
|
||||
- การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern
|
||||
`verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ
|
||||
- middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้
|
||||
|
||||
## 4. โค้ดแชร์ — `utils/docver-ticket.ts` (pure, ไม่มี dependency เลย)
|
||||
|
||||
```ts
|
||||
export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string }
|
||||
export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string }
|
||||
export function isTicketExpired(exp: number, nowSec?: number): boolean // exp <= now
|
||||
export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$
|
||||
export function timingSafeEqualHex(a: string, b: string): boolean // constant-time บน hex ที่ normalize แล้ว
|
||||
export type HmacSha256Hex = (message: string, secret: string) => Promise<string> // adapter ที่สองฝั่งฉีด
|
||||
```
|
||||
|
||||
- `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน —
|
||||
schema drift กันได้จากฝั่งเดียว
|
||||
|
||||
## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement)
|
||||
|
||||
`TaskpaneApp.detect()` → `authService.signInWithTicket({ticketData, ticketHmac})`:
|
||||
|
||||
```
|
||||
1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties)
|
||||
2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์"
|
||||
parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข"
|
||||
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
|
||||
3. session = localStorage["wordaddin-auth"] (fallback memory)
|
||||
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
|
||||
· ticket หมดอายุ → no-access "ticket หมดอายุ"
|
||||
· เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
|
||||
· เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
|
||||
5. uid ตรงกัน (คนเดิม):
|
||||
· tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
|
||||
— token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
|
||||
— token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
|
||||
· tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
|
||||
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
|
||||
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
|
||||
```
|
||||
|
||||
- `authService` surface ใหม่: `signInWithTicket` / `getAccessToken` (token ของ fid ปัจจุบัน) /
|
||||
`renewTokenFromTicket` / `getUsername` — ถอด `signInFromDocument` + `decodeJwtField` สำหรับ uid
|
||||
(ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ `exp` ของ JWT ตอนเก็บ token)
|
||||
- `apiClient` ตอนโดน 401: `renewTokenFromTicket()` → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี
|
||||
ticket = `AuthRequiredError` (UI ไป no-access — เดิมมีอยู่แล้ว)
|
||||
- `?addindemo` ยังใช้ได้ (`signInForDemo` คงเดิม)
|
||||
|
||||
## 6. ไฟล์ที่สร้าง/แต้/ถอด
|
||||
|
||||
**สร้าง**
|
||||
- `utils/docver-ticket.ts` — §4
|
||||
- `lib/addin-ticket.ts` — server-only: `addinTicketSecret()` (env `ADDIN_TICKET_SECRET` trim หรือ default
|
||||
`"super_secret"`) + `verifyAddinTicket(ticketData, hmacHex, secret, nowSec?)` (HMAC `node:crypto` +
|
||||
timing-safe + parse + exp) + `ticketToDocverClaims(ticket)` — ห้าม import `@/db`/minio (unit test รันได้เปล่า ๆ)
|
||||
- `wordaddin/services/ticketService.ts` — client: `verifyTicketHmacWeb` (crypto.subtle), session store
|
||||
(`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken`
|
||||
(POST `withBasePath("/api/documents/token")` body ticket)
|
||||
- `scripts/make-ticket-docx.mjs` — §9
|
||||
- `tests/utils/docver-ticket.test.ts`, `tests/lib/addin-ticket.test.ts` (vitest — pattern `tests/lib/docver-token.test.ts`)
|
||||
|
||||
**แต้**
|
||||
- `app/api/documents/token/route.ts` — branch ตาม §3
|
||||
- `wordaddin/config.ts` — `ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"`
|
||||
- `wordaddin/services/metadataService.ts` — ถอด `ECOURT_TOKEN_PROPERTY`/`ECOURT_OWNER_PROPERTY` →
|
||||
`TICKET_DATA_PROPERTY = "ticket_data"` + `TICKET_HMAC_PROPERTY = "HmacSha256"` (`readTrackedProperties`
|
||||
อ่านคู่นี้แทน `ecourtToken`/`ecourtOwner`)
|
||||
- `wordaddin/services/authService.ts` — rewrite ตาม §5
|
||||
- `wordaddin/services/apiClient.ts` — renew-retry ตอน 401
|
||||
- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access 3 แบบ
|
||||
- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket)
|
||||
- `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md`
|
||||
|
||||
**ถอด**
|
||||
- ระบบ `token`/`owner` ทั้งเส้น (properties + `signInFromDocument` + `decodeJwtField` ส่วน uid) —
|
||||
แทนด้วย ticket เต็มตัว
|
||||
|
||||
## 7. Env
|
||||
|
||||
| env | default | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `ADDIN_TICKET_SECRET` | `SECRET` ของกลุ่ม template → `"super_secret"` | server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ `ADDIN_TICKET_SECRET` → `SECRET` (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → `"super_secret"` · **production ต้องตั้งค่าใหม่** (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ |
|
||||
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน |
|
||||
|
||||
> หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template
|
||||
> (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม
|
||||
> `exp` แล้ว HMAC hex lowercase ผ่าน `lib/lib-utils.ts` + stamp ลงไฟล์ด้วย
|
||||
> `setCustomDocProps` ของ `lib/lib-tmpl.ts` อ่าน env `SECRET` กับ `TICKET_SEC` default
|
||||
> 300 วิ — ไฟล์ยังมี property `Ed25519` เพิ่มด้วยแต่ out of scope ของแผนนี้)
|
||||
|
||||
(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที
|
||||
แต่ comment เตือน production ชัด ๆ ทั้ง `.env.example` และแผนนี้)
|
||||
|
||||
## 8. Edge cases
|
||||
|
||||
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
|
||||
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
|
||||
- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว
|
||||
(เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้)
|
||||
- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
|
||||
- **exp == now** → หมดอายุ
|
||||
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
|
||||
- **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น
|
||||
state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว
|
||||
- **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ)
|
||||
|
||||
## 9. สคริปต์ทดสอบ — `scripts/make-ticket-docx.mjs`
|
||||
|
||||
Dev tool รันมือ `node scripts/make-ticket-docx.mjs <file.docx> [options]`:
|
||||
|
||||
- option: `--fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-secret>` และ
|
||||
`--tracked` (stamp `SavedByAddIn`+`DocumentTrackingId` เพื่อทดสอบ phase tracking ด้วย)
|
||||
- ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง `docProps/custom.xml`
|
||||
ผ่าน **pizzip** (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี
|
||||
(พร้อม override ใน `[Content_Types].xml`) · เขียนทับไฟล์เดิม**เฉพาะเมื่อส่ง `--in-place`**
|
||||
ไม่งั้นเขียนไฟล์ใหม่ `<name>.ticket.docx` (กฎ non-destructive ผูกกับ `public/` เท่านั้นแต่ถือหลักเดียวกัน)
|
||||
- default secret/exp สอดคล้อง .env ตัวอย่าง
|
||||
|
||||
## 10. Verify criteria
|
||||
|
||||
1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง)
|
||||
2. `npx tsc --noEmit` ผ่าน
|
||||
3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม
|
||||
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
|
||||
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
|
||||
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" ·
|
||||
exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) ·
|
||||
ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ ·
|
||||
เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
|
||||
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
||||
|
||||
## 11. ลำดับงาน
|
||||
|
||||
1. `utils/docver-ticket.ts` + `tests/utils/docver-ticket.test.ts`
|
||||
2. `lib/addin-ticket.ts` + `tests/lib/addin-ticket.test.ts`
|
||||
3. `app/api/documents/token/route.ts` + `lib/openapi.ts` → verify 10.3 ด้วย curl
|
||||
4. `wordaddin/config.ts` + `metadataService.ts` (properties)
|
||||
5. `ticketService.ts` + `authService.ts` (rewrite)
|
||||
6. `TaskpaneApp.tsx` (detect + ข้อความ) + `apiClient.ts` (renew-retry)
|
||||
7. `scripts/make-ticket-docx.mjs`
|
||||
8. env/docs/CLAUDE.md sync → verify 10.1–10.5
|
||||
|
||||
## 12. Out of scope
|
||||
|
||||
- ~~ปลด mock `versioningService`~~ (ทำแล้ว 2026-10-27 — `versioningService` ยิงกลุ่ม docver จริงผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo` ดู `docs/WORDADDIN-DEV.md`)
|
||||
- ~~ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid)~~ (ยุติแล้ว 2026-10-27 — เอกสารระบุด้วย fid ใน token ฝั่งเดียว; พารามิเตอร์ documentId ของ versioningService เหลือตาม signature เดิมไม่ส่งถึง server)
|
||||
- UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้)
|
||||
231
docs/PLAN-DOCVER.md
Normal file
231
docs/PLAN-DOCVER.md
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||||
|
||||
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
|
||||
> รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService`
|
||||
> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`)
|
||||
|
||||
## 0. บริบท/เป้าหมาย
|
||||
|
||||
ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน
|
||||
`wordaddin/services/versioningService.ts`):
|
||||
|
||||
- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid`
|
||||
(string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก
|
||||
endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร)
|
||||
- แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น
|
||||
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
||||
response)
|
||||
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
||||
- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22
|
||||
— เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
|
||||
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
|
||||
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
|
||||
พาหะ identity เดียว
|
||||
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
|
||||
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
|
||||
ของ N10) · ไม่มี CORS
|
||||
|
||||
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
||||
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
||||
ของต้นฉบับกลับมา (ดู `docs/WORDADDIN-DEV.md`) · MinIO dev รันผ่าน `docker-compose.yml`
|
||||
(ผู้เรียกอัปโหลดทั้ง docx+pdf — ฝั่ง add-in ใช้ Office.js แปลง Word→PDF เอง)
|
||||
|
||||
Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/services/versioningService.ts` +
|
||||
`wordaddin/models/versionRecord.ts` — **โค้ด server ห้าม import จาก `wordaddin/`**
|
||||
(client-only zone) type define เองใน `db/schema.ts`/`lib`
|
||||
|
||||
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
||||
middleware เหมือน `api/template`)
|
||||
|
||||
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
|
||||
|
||||
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
|
||||
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
|
||||
|
||||
**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header
|
||||
`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties
|
||||
ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน
|
||||
`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`)
|
||||
แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`)
|
||||
· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ
|
||||
เดียวกัน · body ผิดรูป = 400)
|
||||
|
||||
JSON body (allowlist — key อื่น = 400 กัน typo):
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
|
||||
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
|
||||
| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid |
|
||||
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
|
||||
|
||||
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
|
||||
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
|
||||
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
|
||||
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
|
||||
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
|
||||
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
|
||||
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
|
||||
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
|
||||
|
||||
### `POST /api/documents/versions` — อัปโหลด version ใหม่
|
||||
|
||||
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ
|
||||
`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป
|
||||
|
||||
multipart/form-data:
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
||||
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
||||
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
|
||||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) |
|
||||
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
|
||||
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
|
||||
|
||||
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) —
|
||||
**breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว
|
||||
(ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน
|
||||
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
|
||||
"สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
|
||||
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
|
||||
(= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server**
|
||||
(client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน)
|
||||
|
||||
### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token)
|
||||
|
||||
response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in +
|
||||
ส่วนเสริม `createdByUserName`):
|
||||
|
||||
```ts
|
||||
{
|
||||
versionId: string; // = id ของ row (uuid)
|
||||
versionNumber: number;
|
||||
createdAt: string; // ISO-8601
|
||||
createdByUserId: string; // = user_id
|
||||
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
|
||||
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
|
||||
fields: Record<string, string> | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
|
||||
}
|
||||
```
|
||||
|
||||
- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ
|
||||
N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง)
|
||||
|
||||
### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf`
|
||||
|
||||
- format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400
|
||||
- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id
|
||||
ข้ามเอกสาร) — ไม่เจอ = 404
|
||||
- `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF"
|
||||
- response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET**
|
||||
(อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ
|
||||
ผู้ใช้ (webview ของ Word ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม)
|
||||
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
|
||||
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
|
||||
|
||||
### ตาราง error
|
||||
|
||||
| เหตุ | สถานะ |
|
||||
|---|---|
|
||||
| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 |
|
||||
| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 |
|
||||
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
|
||||
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
|
||||
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
|
||||
|
||||
## 2. ตาราง (db/schema.ts)
|
||||
|
||||
```
|
||||
documents — id uuid PK · doc_id text UNIQUE NOT NULL · created_at timestamptz
|
||||
document_versions — id uuid PK · document_id uuid FK→documents CASCADE
|
||||
· version_number int NOT NULL (+ UNIQUE(document_id, version_number))
|
||||
· client_version_id · content_hash NOT NULL · message · fields jsonb
|
||||
· template_id · file_name · docx_object_key NOT NULL · pdf_object_key
|
||||
· docx_size int NOT NULL · pdf_size int · user_id NOT NULL
|
||||
· user_name NOT NULL · created_at
|
||||
+ index (document_id, created_at)
|
||||
```
|
||||
|
||||
- **`documents` ≠ `files`** ของแอป note — ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ไหนอ่าน
|
||||
(ชื่อคล้ายกันแต่คนละโดเมน: ตารางนี้เป็นของกลุ่ม API นี้ล้วน ๆ)
|
||||
- `content_hash`/`docx_size` เป็น NOT NULL เพราะ server buffer ไฟล์และคำนวณทั้งคู่ก่อน
|
||||
insert เสมอ · `hasPdf` **ไม่มี column** — derive จาก `pdf_object_key !== null`
|
||||
- object key ใน MinIO: `docver/<versionId>/document.docx` / `document.pdf` — **uuid ล้วน**
|
||||
ไม่มี input ผู้ใช้ (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) · `versionId` สร้าง
|
||||
ด้วย `randomUUID()` ฝั่ง server แล้วส่งเป็น `id` ตอน insert (object เขียนไปก่อนแล้ว)
|
||||
|
||||
## 3. จ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||
|
||||
เลือก: **insert-on-conflict + `SELECT … FOR UPDATE` ใน transaction เดียว** (ใช้ `.for("update")`
|
||||
ที่ repo ใช้อยู่แล้วใน `app/actions/annotations.ts`):
|
||||
|
||||
1. `INSERT INTO documents (doc_id) VALUES (…) ON CONFLICT DO NOTHING` — สร้างเอกสารถ้ายังไม่มี
|
||||
2. `SELECT id FROM documents WHERE doc_id = … FOR UPDATE` — serialize ผู้จ่ายเลขรายเอกสาร
|
||||
(unique `doc_id` → ได้ row เดียวเสมอ ไม่มี deadlock ข้ามเอกสาร)
|
||||
3. `SELECT max(version_number) + 1` แล้ว insert row version
|
||||
|
||||
ทางเลือกที่ตัดทิ้ง: retry-on-23505 (ต้องเก็บกวาด object ซ้ำทุกรอบ ไม่มี ordering) ·
|
||||
`pg_advisory_xact_lock` (ได้แต่ต้อง raw SQL และซ้ำซ้อนกับ row lock) — UNIQUE
|
||||
`(document_id, version_number)` เก็บไว้เป็น safety net
|
||||
|
||||
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า
|
||||
|
||||
validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId`
|
||||
→ **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
|
||||
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
|
||||
|
||||
- crash ระหว่าง putObject↔insert เหลือ object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร) —
|
||||
**ไม่ทำ GC รอบนี้** บันทึกไว้เป็นงานอนาคต
|
||||
- ไม่มี idempotency บน `clientVersionId` — retry หลัง network timeout เกิด version ใหม่
|
||||
(client แก้เลขจาก response เองได้) ยอมรับได้
|
||||
|
||||
## 5. Carve-out จาก invariant #4 (CLAUDE.md)
|
||||
|
||||
กลุ่ม API นี้ **เขียน DB ผ่าน route handler** (ไม่ใช่ Server Action) และไม่ publish SSE
|
||||
ไม่ `revalidatePath` — ตาราง `documents`/`document_versions` เป็นของกลุ่มนี้ล้วน ไม่มีหน้า
|
||||
note ใดอ่าน จึงไม่มี Router Cache/SSE ที่ต้อง invalidate (นับเป็นข้อยกเว้นเดียวกับลักษณะ
|
||||
read-only ของ N10 แต่ตัวนี้เขียนได้)
|
||||
|
||||
## 6. Env (อ่านตอนเรียก ไม่ใช่ตอน module load — ขาด/ล้ม = 500 ไทยกลาง ๆ + log เต็มฝั่ง server)
|
||||
|
||||
| env | default | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `MINIO_ENDPOINT` | — บังคับ | host อย่างเดียว ไม่มี scheme |
|
||||
| `MINIO_PORT` | `9000` | dev compose แมพโฮสต์เป็น 19000 (9000/9001 ชน service อื่นในเครื่อง) |
|
||||
| `MINIO_USE_SSL` | `false` | |
|
||||
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
|
||||
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
|
||||
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
|
||||
| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ |
|
||||
| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
|
||||
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
|
||||
|
||||
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
|
||||
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
|
||||
ตอนดาวน์โหลด
|
||||
|
||||
## 7. ไฟล์ของกลุ่มนี้
|
||||
|
||||
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
|
||||
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
|
||||
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
|
||||
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer`
|
||||
+ `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`)
|
||||
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
||||
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
|
||||
- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key)
|
||||
- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token)
|
||||
- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET
|
||||
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
|
||||
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
|
||||
- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure
|
||||
|
||||
## 8. Out of scope รอบนี้
|
||||
|
||||
- ~~ไม่แต้ `wordaddin/` หรือ client ใด ๆ~~ (ปลด mock แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง")
|
||||
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
|
||||
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)
|
||||
File diff suppressed because one or more lines are too long
155
docs/PLAN-TOOLBAR.md
Normal file
155
docs/PLAN-TOOLBAR.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# PLAN-TOOLBAR — Test + แก้ไข logic แถบ tool ของหน้า annotate
|
||||
|
||||
> สร้าง 2026-09-29 จาก TDD session — ขั้นตอนไหนจะทำเมื่อไรให้รอคำสั่ง
|
||||
> (อ้างอิงบรรทัดจาก HEAD 8571ef9)
|
||||
>
|
||||
> **ความคืบหน้า:** ขั้น 1 + ขั้น 2 (รวม findings #2/#5) + findings #1/#3/#4/#6
|
||||
> เสร็จแล้ว 2026-09-29 (ยังไม่ commit) — เหลือเฉพาะขั้น 4 (รออาการ bug เฉพาะ)
|
||||
> และรายการ "จดไว้" #7–#10, #13–#14
|
||||
|
||||
## สถานะปัจจุบัน
|
||||
|
||||
- **ขั้น 1 ทำเสร็จ 2026-09-29:** spec test ฉบับแรกถูก revert ไปพร้อมโค้ดรอบก่อน
|
||||
(git มีแต่แผนนี้) จึงเขียนใหม่ตามเกณฑ์เดิม (literal จาก rules + 8571ef9
|
||||
ไม่คัดลอกสูตร — กัน tautological) → ยืนยันแดงที่ import ก่อน implement →
|
||||
สร้าง `components/pdf/toolbar-tools.ts` + rewire PDFViewer + ตัด icon imports
|
||||
ว่าง 9 ตัว — vitest **204/204** + `tsc --noEmit` สะอาด + code-reviewer ผ่าน
|
||||
(ไม่มี findings ต้องแก้/ควรแก้ — จุดจดไว้อยู่ท้ายตารางขั้น 3 #11–#12)
|
||||
- **ขั้น 2 + findings #2/#5 ทำเสร็จ 2026-09-29:** ทางเลือก (ก) — `TOOL_MENU_KEYS`
|
||||
+ `type MenuTool` ใน toolbar-tools.ts, `TOOL_MENU_TABLE` (Record บังคับครบทุก
|
||||
key = เพิ่มเมนูลืมแถวก็ compile fail) + `closeToolMenus()`/`closePagePopups()`
|
||||
ใน PDFViewer ใช้ร่วมครบ 6 กลุ่มจุดเดิม (switchTool/outside-click/เลื่อนแถบ·
|
||||
resize/auto-select/openBookmarkComposer/reset tool ตรง 2 จุดที่เดิมไม่เคลียร์
|
||||
เลย — scannedOnly + สลับไฟล์ภาพ) — finding #2 แก้: `openBookmarkComposer`
|
||||
เรียก `closePagePopups()` ก่อนเปิด popup bookmark — test เพิ่ม TOOL_MENU_KEYS
|
||||
cross-check + truth table เคสที่ 4 (**205/205**) + e2e Playwright 8/8
|
||||
(กดแก้ไข comment → กด B = popover/editor ปิดก่อน popup bookmark โผล่ + focus
|
||||
ย้ายเข้าช่องชื่อ; เมนู highlight ค้าง → สลับไฟล์ภาพ = ปุ่มหาย เมนูไม่ค้าง) +
|
||||
sync rules/pdf-viewer.md 2 จุด
|
||||
- **findings #1 แก้เสร็จ 2026-09-29:** คีย์ลัดจับคู่ด้วย `e.code` แทน `e.key` —
|
||||
`TOOL_SHORTCUTS` (KeyH/KeyD/KeyC → tool) ใน toolbar-tools.ts, handler ใช้
|
||||
`e.code` ครบ H/D/C/B **+ Ctrl+Z undo/redo** (ขยายครอบตามเหตุผลเดียวกัน —
|
||||
layout ไทยให้ e.key "ฎ" ทำให้ undo ตายเหมือนกัน), delete/backspace ใช้
|
||||
canonical `e.key` ต่อ (เหมือนทุก layout) — unit 207/207 (เพิ่ม 2 เคส
|
||||
TOOL_SHORTCUTS + cross-check คู่ label `(H)/(D)/(C)` กัน drift) + e2e
|
||||
synthetic Thai keydown (e.key อักษรไทย + e.code ตำแหน่งปุ่ม) **6/6** +
|
||||
rules sync (bullet คีย์ลัด)
|
||||
- **แก้ตาม code review ขั้น 2 (รอบเดียวกัน):** ไม่มี bug — แก้ 3 จุด: (a) ⚠️
|
||||
`handleAnnotationBookmark` (ปุ่ม bookmark ในแผง selection N2 — ทางเปิด popup
|
||||
bookmark ทางเดียวที่ยังไม่ผ่านชุดเคลียร์) เพิ่ม `closeToolMenus()` +
|
||||
`closePagePopups()` ต้น function ให้จบ finding #2 ครบทุกทาง (b) ตัด unused
|
||||
import `TOOLS` ที่ขั้น 1 ตกค้าง (c) ก้อน Esc ของคีย์ลัด (7 setter) แทนด้วย
|
||||
`closePagePopups()` — reviewer เทียบ 1:1 เป๊ะ behavior-preserving — จุด
|
||||
enumerate setter ด้วยมือฝั่ง popup หมดสิ้น — tsc + 205/205 + e2e rerun เคส
|
||||
A (B/Esc path) **5/5**
|
||||
- ขั้น 3 ขึ้นไป: codebase ยังไม่ถูกแตะ รอคำสั่ง
|
||||
- reviewer 2 ตัว (code-reviewer + plan-reviewer) ตรวจพื้นที่ toolbar แล้ว —
|
||||
สรุป findings ในขั้น 3
|
||||
|
||||
## ขั้น 1 — ทำ spec แดงให้เขียว: สร้าง `components/pdf/toolbar-tools.ts`
|
||||
|
||||
pure module (ไม่มี React/state) export 5 อย่างตาม spec:
|
||||
|
||||
| API | เนื้อที่ย้ายจาก PDFViewer.tsx (behavior-preserving 100%) |
|
||||
|---|---|
|
||||
| `type Tool` + `ToolEntry` | 156-166 |
|
||||
| `TOOLS` + comment เต็ม | 379-398 |
|
||||
| `visibleToolsFor(hide)` | 3161 |
|
||||
| `canSwitchTo(t, hide)` | guard 3114 |
|
||||
| `toolButtonFace(active, {label,text}, value?)` | pattern เดียวกัน 3391-3397 / 3487-3489 / 3597-3600 / 3686-3691 |
|
||||
| `menuIntentAfterClick(cur, key, open)` | `reopen` 3414 / 3504 / 3615 / 3713 / 3941 |
|
||||
|
||||
Rewire ใน PDFViewer: import แทน definition เดิม + แทน inline ternary 4 ปุ่ม
|
||||
dropdown ด้วย `toolButtonFace` + แทน `reopen` 5 จุด + **ตัด icon imports ที่ว่าง
|
||||
9 ตัว** (Eraser/EyeOff/Hand/Highlighter/MessageSquarePlus/MousePointer2/Shapes/
|
||||
Type/Stamp — เหลือ Pencil, LucideIcon ที่ยังใช้อื่น)
|
||||
|
||||
**Verify:** test 11 เคสเขียว + ทั้งชุด 204/204 + `tsc --noEmit` สะอาด
|
||||
(เคย implement ผ่านเต็มมาแล้วรอบหนึ่งก่อนคืนตามคำสั่ง — ขั้นตอนนี้ทำซ้ำได้เลย)
|
||||
|
||||
## ขั้น 2 — switchTool cascade: รวมชุด "ปิดเมนู/popup" ที่เขียนซ้ำ 6 แห่ง
|
||||
|
||||
**ปัญหา:** ชุด `set*MenuOpen(false)` ×5 enumerate ด้วยมือซ้ำกัน 6 จุด —
|
||||
3118-3126 (switchTool), 1968-1971 (outside-click), 1538-1544 (เลื่อนแถบ/resize),
|
||||
2442-2446 (openBookmarkComposer), 2146-2151 (auto-select), + setTool ตรง 2 จุด
|
||||
(1617-1619 scannedOnly, 1691 สลับไฟล์ภาพ) ที่**ไม่เคลียร์อะไรเลย** → เพิ่มเมนู
|
||||
ใหม่/แก้พฤติกรรมแล้วลืมแห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม
|
||||
active ใช้ title match เสียเงียบตั้งแต่ 2026-09-23 — commit 8571ef9)
|
||||
|
||||
**ทางเลือก (เลือกตอน implement):**
|
||||
- **(ก) เบา — แนะนำ:** helper เดียวใน toolbar-tools.ts เช่น `TOOL_MENU_KEYS`
|
||||
+ `resetMenus()`/`resetPagePopups()` ใช้ร่วมทั้ง 6 จุด; test ยืนยัน helper ครบ
|
||||
ทุกเมนูที่มี dropdown (cross-check กับ TOOLS — กันลืมตัวใหม่)
|
||||
- **(ข) หนัก:** ย้ายก้อน state menu/popup (~10 ตัว) ไป `useReducer` เดียว —
|
||||
test ครบทั้ง transition แต่ diff ใหญ่ในไฟล์ ~4,000 บรรทัด เสี่ยงเกินผล
|
||||
|
||||
**แก้พวงในขั้นเดียวกัน (ต้องแตะจุดเดียวกัน):**
|
||||
- `openBookmarkComposer` (2438-2458) ปิดเมนูแต่**ไม่เคลียร์ popup บนหน้า**
|
||||
(`editingCommentId`/`openPinId`/`selectedId`/`composer`/`textboxComposer`/
|
||||
`editingTextboxId`) → popup bookmark ซ้อน popover/editor ที่ค้างเปิด +
|
||||
autoFocus แย่ง focus — ผิดหลัก "popup เปิดได้ทีละชุด" (rules ข้อ 30)
|
||||
- setTool ตรง 2 จุด (1617-1619, 1691) ให้ผ่านชุดเคลียร์เดียวกัน — edge:
|
||||
เมนู highlight ค้างเปิด → ไฟล์จริงเป็นสแกนล้วน → ปุ่มหายแต่เมนู portal
|
||||
ยังลอยไร้ปุ่มยึด
|
||||
- จุด `anyMenuOpen` อ่านจาก closure ของ render (3118) — ปัจจุบันเวิร์กเพราะ
|
||||
outside-click flush ก่อนเสมอ แต่ไม่ enforced ที่จุดเดียว; ใส่ comment
|
||||
กำกับ ordering นี้ตอนรวม helper
|
||||
|
||||
**Verify:** test helper ครบทุกเมนู + เดินมือ/Playwright: กด "แก้ไข" comment →
|
||||
กด B = popover ปิดก่อน popup bookmark โผล่; เปิดเมนู highlight → เปิดไฟล์ภาพ
|
||||
(สลับ dynamic param) = เมนูไม่ค้าง
|
||||
|
||||
## ขั้น 3 — findings จาก reviewer แปลงเป็นรายการแก้ (จัดลำดับ)
|
||||
|
||||
| # | ประเด็น | ที่ | แนวทาง | ลำดับ |
|
||||
|---|---|---|---|---|
|
||||
| 1 | ~~**คีย์ลัด H/D/C/B ใช้ไม่ได้บน layout ภาษาไทย**~~ **แก้เสร็จ 2026-09-29** — `TOOL_SHORTCUTS` (map `e.code`→tool) ใน toolbar-tools.ts + handler ใช้ `e.code` ทั้ง H/D/C/B และ **Ctrl+Z undo/redo ด้วย** (ขยายครอบตามเหตุผลเดียวกัน — e.key บน layout ไทยเป็น "ฎ" ทำให้ undo ตายเหมือนกัน) + test cross-check คู่ label `(H)/(D)/(C)` + e2e synthetic Thai keydown 6/6 | เดิม 2056-2070 | — | **เสร็จ** |
|
||||
| 2 | popup bookmark ซ้อน editor (รายละเอียดขั้น 2) | 2438-2458 | รวมกับขั้น 2 | สูง (ทำพร้อมขั้น 2) |
|
||||
| 3 | ~~โควตา coarse hint 3 ครั้ง นับเกิน~~ **แก้เสร็จ 2026-09-29** — นับเฉพาะ `enteringHighlight` (เพิ่งสลับเข้าจาก tool อื่น — กดปุ่ม dropdown เปิด/พับเมนูตอน active อยู่แล้ว และกด H ซ้ำ ไม่กินโควตา) | เดิม 3136-3138 | — | **เสร็จ** |
|
||||
| 4 | ~~กด H บนไฟล์ภาพ/สแกนล้วน = no-op เงียบสนิท~~ **แก้เสร็จ 2026-09-29 (ทางเบา)** — เก็บ tool เดิมไว้ แต่ผ่านชุดเคลียร์เมนู+popup+selection เหมือนสลับ tool จริง (toast บอกเหตุผลเป็นทางเลือกที่**ยังไม่ทำ** — จิตวิทยา minimal ของผู้ใช้) | เดิม 3114 | — | **เสร็จ** |
|
||||
| 5 | โครงซ้ำ 6 แห่ง (ขั้น 2) | ดูขั้น 2 | ขั้น 2 | สูง (พร้อมขั้น 2) |
|
||||
| 6 | ~~`aria-pressed={tool === "textbox"}` ไม่ใช้ตัวแปร `active`~~ **แก้เสร็จ 2026-09-29** — เปลี่ยนเป็น `active` พร้อมชุด #3/#4 | เดิม 3934 | — | **เสร็จ** |
|
||||
| 7 | `aria-pressed` คู่ `aria-haspopup="menu"` ชนกันตาม WAI-ARIA (toggle vs menu button) ทั้ง 5 ปุ่ม | 3405-3407 ฯลฯ | พิจารณาคง `aria-expanded` เดี่ยว — **เป็น behavior change ต้องขออนุมัติแยก** | ต่ำ/เลือกได้ |
|
||||
| 8 | `liveMark`/`selectPreview` เคลียร์ผ่าน side effect ของ `removeAllRanges()` → `selectionchange` → effect ไม่ใช่ state ตรง — เวิร์กแต่ coupling ซ่อนอยู่ | 2681-2735, 3134 | ไม่แก้ — บันทึกความเสี่ยงไว้ใน comment ใกล้ switchTool | จดไว้ |
|
||||
| 9 | ทางทฤษฎี: ความกว้าง `main` เปลี่ยนโดย window คงเดิม ไม่ปิดเมนู (ทางเข้าสลับ sidebar ผ่าน outside-click ก่อนเสมอจึงไม่เจอจริง) | 1546-1548 | ไม่แก้ — จดไว้ | จดไว้ |
|
||||
| 10 | เคสจิ๋ว: แอนิเมชันเลื่อนปุ่ม active (guard 600ms) กลืน scroll ของผู้ใช้ → เมนูเพี้ยนจากปุ่ม | 1552, 1586 | ต้อง track จบจริง (`scrollend`) — แพงเกินปัญหา | จดไว้ |
|
||||
| 11 | ช่องว่าง exhaustiveness ของ BTNS arrays: ถ้าขยาย `HighlightStyle`/`ShapeKind` ใน schema โดยไม่เพิ่มแถว `HIGHLIGHT_STYLE_BTNS`/`SHAPE_KIND_BTNS` (array ธรรมดา ไม่มี type บังคับครอบ union) ปุ่ม active จะแสดง `text` + title `label` เฉย ๆ (เดิม: title `"label — text"`) — ทางแก้ยาว: BTNS เป็น `Record<HighlightStyle, …>` หรือ `satisfies` เต็ม union | ToolControls.tsx:280, PDFViewer (SHAPE_KIND_BTNS) | ไม่แก้ตอนนี้ (ขัด behavior-preserving ของขั้น 1) — แก้เมื่อขั้นไหนแตะ BTNS | จดไว้ (code review ขั้น 1) |
|
||||
| 12 | ~~test `menuIntentAfterClick` ยังไม่ครอบ truth table เคสที่ 4~~ **แก้แล้ว 2026-09-29 ระหว่างขั้น 2** | tests/components/toolbar-tools.test.ts | — | เสร็จ |
|
||||
| 13 | deps ของ outside-click effect เป็น spread array `[...TOOL_MENU_KEYS.map(...)]` — runtime ถูกต้อง (ความยาว/ลำดับคงที่ ค่า primitive) แต่ react-hooks/exhaustive-deps lint มองไม่เห็นค่าใน spread — โปรเจกต์ไม่มี lint script จึงไร้ผลตอนนี้ | PDFViewer outside-click effect deps | จดไว้กันงงถ้าอนาคตเปิด eslint | จดไว้ (code review ขั้น 2) |
|
||||
| 14 | `closeToolMenus`/`TOOL_MENU_TABLE` นิยามหลังจุดใช้เมื่อไล่อ่านจากบน (เรียกครั้งแรกที่ effect resize ~1538 นิยามที่ ~1962) — ไม่ใช่ bug (hoisted + effect รัน post-render + setter เสถียร) แค่อ่านยาก | PDFViewer | ย้ายขึ้กลุ่ม state เมนูเมื่อแตะจุดนั้นครั้งถัดไป | จดไว้ (code review ขั้น 2) |
|
||||
| 15 | Ctrl+Alt+Z / Cmd+Option+Z ยิง undo เพิ่มจาก HEAD ในบาง layout (Windows จำลอง AltGr เป็น Ctrl+Alt — HEAD เช็ค e.key ที่ AltGr+Z แผ่อักขระพิเศษจึงไม่ตรง แต่ e.code ยิงเสมอ) — แทบไม่เกิดกับ layout ไทย/อังกฤษ ไม่ทำลายอะไร | PDFViewer ctrl branch | ถ้าต้องกัน: `!e.altKey` ในเงื่อนไข KeyZ บรรทัดเดียว | จดไว้ (code review #1/#3/#4/#6) |
|
||||
| 16 | `TOOL_SHORTCUTS[e.code]` type เป็น `Tool` ไม่ใช่ `Tool \| undefined` (ไม่มี `noUncheckedIndexedAccess`) — ปลอดภัยเพราะมี guard runtime แต่ถอด guard ทิ้ง tsc ไม่ช่วยจับ | toolbar-tools.ts | เปลี่ยนเป็น `Partial<Record<string, Tool>>` ถ้าอยากให้ compiler บังคับ | จดไว้ (code review #1/#3/#4/#6) |
|
||||
| 17 | `e.code` บนคีย์บอร์ดจอ (software keyboard) ของ iPad เคยมีรายงานว่าเป็นค่าว่างใน WebKit บางเวอร์ชัน — e2e ยืนยันด้วย synthetic event บน desktop เท่านั้น (hardware keyboard ปลอดภัยแน่นอน) | PDFViewer keydown | ถ้าได้รายงาน "คีย์ลัดตายบนคีย์บอร์ดจอ iPad" เช็คจุดนี้อันดับแรก + ทดสอบบนเครื่องจริง | จดไว้ (code review #1/#3/#4/#6) |
|
||||
|
||||
**ช่องว่าง test เพิ่ม (ตาม plan-reviewer):** ค่า face ของ stamp (`"กำหนดเอง"` เมื่อ
|
||||
`stampPreset` ไม่ตรง preset), การ reset ค่ากลับ preset ตอนเลือก pen (3541-3546),
|
||||
ยืนยัน textbox ไม่มีค่าย่อย — เพิ่มใน suite เดียวกันตอนขั้น 1/2; DOM-level
|
||||
(toggle เมนู / outside-click + tap 300ms / Escape สองชั้น / `data-tool` ครบ 10 ปุ่ม)
|
||||
ต้อง jsdom/e2e — แยกเป็นรอบหลัง
|
||||
|
||||
## ขั้น 4 — อาการเฉพาะจากผู้ใช้
|
||||
|
||||
**รอระบุ** — ผู้ใช้เลือกไว้ว่ามีอาการ bug เฉพาะที่เจอเอง แต่ยังไม่ได้บอกรายละเอียด
|
||||
เมื่อได้อาการแล้ว: เพิ่มเป็นหัวข้อ "เคส F" พร้อม test reproduce ก่อนแก้ตามลูป TDD
|
||||
|
||||
## Carve-outs ที่ห้าม "แก้ให้ตรงกติกา" (จาก plan-reviewer — ทำลายได้ถ้ารวมเช่นเดียวกัน)
|
||||
|
||||
1. แถวเดี่ยวเลือกจบ 4 เมนู (3451-3455, 3541-3549, 3650-3654, 3869-3873) +
|
||||
`finishCustom` (3695-3698) **ปิดเมนูไม่ผ่าน `markMenuClosed` โดยเจตนา** —
|
||||
tap ถัดไปต้องวางรายการได้ทันที (กลืน tap 300ms จะฆ่า flow วางตรายาง)
|
||||
2. คีย์ B ปิดเมนูไม่ mark — คีย์บอร์ดไม่มี click หลงเหลือ
|
||||
3. Escape สองชั้น (1973-1979 มี stopPropagation) — ครั้งแรกปิดเมนูอย่างเดียว
|
||||
4. ลำดับ guard ของ `handlePageClick` (2916-2988): isCollapsed → 300ms → ปิด
|
||||
bookmark/pin → deselect → branch ตาม tool — ห้ามสลับ
|
||||
5. `menuOpenWRef` ตั้ง 2 ทาง (openToolMenu + positionBookmarkPopup) — resize
|
||||
เฉพาะความกว้างครอบ popup bookmark ด้วย
|
||||
6. `selfScrollUntilRef` ต้องไม่ trigger การปิดเมนู (1529, 1586)
|
||||
7. `stampCustomMode` reset เฉพาะตอนเปิดเมนูใหม่ผ่านปุ่ม (3717)
|
||||
8. `scannedOnly` เป็น latch ของ `pageSpanCount` — ไม่ลบตอน virtualization unmount
|
||||
|
||||
## ลำดับการทำเมื่ออนุมัติ
|
||||
|
||||
1. ขั้น 1 (test เขียว) → 2. ขั้น 2 + findings #2/#5 → 3. findings #1 (คีย์ลัด
|
||||
ไทย — แจ้ง behavior change ก่อน) → #3 → #4 → #6 → 4. ขั้น 4 เมื่อได้อาการ
|
||||
ทุกขั้นจบด้วย: `tsc --noEmit` สะอาด + vitest ทั้งชุดผ่าน + sync
|
||||
`.claude/rules/pdf-viewer.md` ถ้าพฤติกรรมเปลี่ยน + code review
|
||||
92
docs/WORDADDIN-DEV.md
Normal file
92
docs/WORDADDIN-DEV.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
|
||||
|
||||
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
|
||||
|
||||
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
|
||||
|
||||
สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer จาก ticket: Save & Version ขึ้น MinIO จริง ดาวน์โหลดเปิด presigned URL ในเบราว์เซอร์; เหลือ mock เฉพาะโหมด `?addindemo` เพื่อดู UI ใน browser โดยไม่ต้องมี backend — ดูหัว "การรันกับ backend จริง")
|
||||
|
||||
## ไฟล์หลัก
|
||||
|
||||
| ที่ | คือ |
|
||||
|---|---|
|
||||
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
|
||||
| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) |
|
||||
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler |
|
||||
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) |
|
||||
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ |
|
||||
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
|
||||
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
|
||||
|
||||
## Env
|
||||
|
||||
| ตัวแปร | default | ใช้เมื่อไหร่ |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) |
|
||||
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) |
|
||||
| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** |
|
||||
|
||||
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
|
||||
|
||||
manifest ต้องชี้ https — 2 ทางเลือก:
|
||||
|
||||
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
|
||||
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
|
||||
|
||||
## 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"
|
||||
|
||||
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) → `detect()` อ่าน custom properties ไม่ได้ (ไม่มี `Word.run`) → เห็น `no-access` — ตรวจ state machine ได้แค่นี้ใน browser ปกติ ส่วน phase `untracked`/`tracking` ต้องใช้ demo ด้านล่าง (หรือ sideload ใน Word จริง)
|
||||
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
|
||||
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
|
||||
|
||||
## ทดสอบ e-Court ticket (auth ทางเข้าเดียว)
|
||||
|
||||
ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ
|
||||
|
||||
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template):
|
||||
|
||||
```bash
|
||||
# สร้าง <file>.ticket.docx (ไม่แตะไฟล์เดิม) exp +5 นาที เท่า TICKET_SEC ของตัว stamp
|
||||
node scripts/make-ticket-docx.mjs path/to/file.docx --fid DEMO_FID_001 --uid oom --uname "สรวิชญ์" --tid T12
|
||||
# ดู custom properties ที่มีในไฟล์
|
||||
node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list
|
||||
# ทดสอบ phase tracking ด้วย (stamp SavedByAddIn + DocumentTrackingId เพิ่ม)
|
||||
node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place
|
||||
```
|
||||
|
||||
แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7
|
||||
|
||||
หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์
|
||||
|
||||
## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27)
|
||||
|
||||
- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court)
|
||||
- **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS)
|
||||
- **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400)
|
||||
- **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้)
|
||||
- **mock เหลือเฉพาะโหมด `?addindemo`** (dev-only — ดู UI ใน browser ไม่ต้องมี backend: version เก็บในเครื่อง metadata ใน localStorage `wordaddin-mock-versions`, download-link คืน about:blank — เคลียร์ด้วยการลบ key นี้หรือ clear storage ของ origin)
|
||||
- ชุด unit test ของ services: ต้นฉบับ jest อยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` — ฝั่งนี้เขียนใหม่เป็น vitest เฉพาะส่วนที่เป็นโค้ดใหม่ (ticket auth + versioningService สองโหมด ที่ `tests/wordaddin/`)
|
||||
|
||||
## Docker
|
||||
|
||||
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ runner stage คัดลอก `public/` เข้า image เอง (`COPY /app/public ./public` — **Next 14.2 standalone ไม่รวม `public/` ให้อัตโนมัติ** ต้อง copy เองตามเอกสาร deploy ของ Next; เคยเข้าใจผิดว่า Next รวมให้ เพราะบางไฟล์หลุดเข้า image ผ่าน output file tracing ของ Next ที่ตาม `fs.readFile` ใน server code — แต่ `public/wordaddin/office-js` ไม่มีใครอ่านผ่าน fs จึงไม่ถูกดึงมา แล้ว taskpane พังใน production 2026-10-16)
|
||||
|
||||
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile
|
||||
319
docs/addin-ui-flow.md
Normal file
319
docs/addin-ui-flow.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
|
||||
|
||||
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
|
||||
|
||||
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. State machine หลัก
|
||||
|
||||
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` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ
|
||||
|
||||
**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`)
|
||||
|
||||
```
|
||||
checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access
|
||||
│
|
||||
├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked
|
||||
└─(ticket ผ่าน + tracked doc)────────→ tracking
|
||||
```
|
||||
|
||||
การเปลี่ยน phase อื่นๆ:
|
||||
|
||||
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
|
||||
- ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access`
|
||||
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ)
|
||||
|
||||
State ที่ UI ถือนอกเหนือจาก phase:
|
||||
|
||||
| State | ความหมาย |
|
||||
|---|---|
|
||||
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
|
||||
| `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header |
|
||||
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
|
||||
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
|
||||
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
|
||||
| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) |
|
||||
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
|
||||
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
|
||||
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
|
||||
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
|
||||
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
|
||||
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
|
||||
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
|
||||
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
|
||||
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
|
||||
|
||||
---
|
||||
|
||||
## 2. UI แต่ละ phase
|
||||
|
||||
### Header (ทุก phase)
|
||||
|
||||
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
|
||||
- ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title`
|
||||
|
||||
### `checking`
|
||||
|
||||
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
|
||||
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
|
||||
|
||||
### `no-access`
|
||||
|
||||
- ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้"
|
||||
- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ):
|
||||
- ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"
|
||||
- ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย"
|
||||
- ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"
|
||||
- ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง"
|
||||
- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น
|
||||
|
||||
### `untracked`
|
||||
|
||||
เรียงจากบนลงล่าง:
|
||||
|
||||
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
|
||||
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
|
||||
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
|
||||
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
|
||||
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
|
||||
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
|
||||
7. `flowError` → MessageBar **error**
|
||||
|
||||
### `tracking`
|
||||
|
||||
เรียงจากบนลงล่าง:
|
||||
|
||||
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
|
||||
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
|
||||
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
|
||||
4. `flowError` → MessageBar **error**
|
||||
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
|
||||
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
|
||||
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
|
||||
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
|
||||
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
|
||||
|
||||
---
|
||||
|
||||
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
|
||||
|
||||
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
|
||||
|
||||
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
|
||||
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
|
||||
- สถานะตามลำดับ:
|
||||
- `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 แล้ว
|
||||
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
|
||||
|
||||
พฤติกรรมปุ่ม:
|
||||
|
||||
| ปุ่ม | Flow |
|
||||
|---|---|
|
||||
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
|
||||
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
|
||||
|
||||
---
|
||||
|
||||
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
|
||||
|
||||
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
|
||||
|
||||
| Prop | Save & Version | Start Versioning |
|
||||
|---|---|---|
|
||||
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
|
||||
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
|
||||
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
|
||||
|
||||
ภายใน dialog:
|
||||
|
||||
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
|
||||
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
|
||||
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
|
||||
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
|
||||
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
|
||||
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
|
||||
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
|
||||
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
|
||||
|
||||
---
|
||||
|
||||
## 5. `VersionHistoryPanel` (FR-4)
|
||||
|
||||
- Header: icon history + "Version history" (semibold)
|
||||
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
|
||||
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
|
||||
- `Badge v{versionNumber}` (สี brand)
|
||||
- timestamp รูปแบบ medium date + short time ตาม locale
|
||||
- `โดย {createdByUserId}`
|
||||
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
|
||||
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
|
||||
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
|
||||
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
|
||||
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow หลักพร้อมข้อความ progress
|
||||
|
||||
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
|
||||
|
||||
### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่
|
||||
|
||||
1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
|
||||
2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ
|
||||
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
|
||||
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
|
||||
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
|
||||
|
||||
### Start Versioning (FR-1) — จาก `untracked`
|
||||
|
||||
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
|
||||
|
||||
1. `กำลังบันทึก metadata ของเอกสาร…`
|
||||
2. `กำลังอัปเดต version history ในไฟล์…`
|
||||
3. `กำลังบันทึกไฟล์…`
|
||||
4. `กำลังอ่านค่า fields จากเอกสาร…`
|
||||
5. `กำลังอัปโหลด version 1…`
|
||||
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
|
||||
7. `กำลังอัปโหลด version…`
|
||||
|
||||
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
|
||||
|
||||
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
|
||||
|
||||
### Save & Version (FR-2) — จาก `tracking`
|
||||
|
||||
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
|
||||
|
||||
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
|
||||
|
||||
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
|
||||
|
||||
### Download version (FR-4/FR-7)
|
||||
|
||||
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
|
||||
|
||||
### Auto-save (FR-17)
|
||||
|
||||
- เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้)
|
||||
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
|
||||
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
|
||||
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
|
||||
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
|
||||
|
||||
---
|
||||
|
||||
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
|
||||
|
||||
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
|
||||
|
||||
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
|
||||
|
||||
multipart/form-data:
|
||||
|
||||
| field | type | หมายเหตุ |
|
||||
|---|---|---|
|
||||
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
|
||||
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
|
||||
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
|
||||
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
|
||||
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
|
||||
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
|
||||
|
||||
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
|
||||
|
||||
### `GET /api/documents/{documentId}/versions` — history
|
||||
|
||||
response: `VersionRecord[]` เรียงใหม่ → เก่า:
|
||||
|
||||
```ts
|
||||
interface VersionRecord {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
createdAt: string; // ISO-8601
|
||||
createdByUserId: string;
|
||||
contentHash: string; // SHA256 hex
|
||||
message: string | null; // commit message (FR-13)
|
||||
hasPdf?: boolean;
|
||||
fields: FieldsSnapshot | null;
|
||||
}
|
||||
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
|
||||
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
|
||||
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
|
||||
```
|
||||
|
||||
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
|
||||
|
||||
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
|
||||
|
||||
### Auth — e-Court ticket (ไม่มี login)
|
||||
|
||||
ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md)
|
||||
|
||||
> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs`
|
||||
|
||||
---
|
||||
|
||||
## 8. กติกา error ที่ UI ต้องรองรับ
|
||||
|
||||
1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
|
||||
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
|
||||
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
|
||||
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
|
||||
|
||||
---
|
||||
|
||||
## 9. ข้อควรระวังที่กระทบหน้าตา UI
|
||||
|
||||
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
|
||||
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
|
||||
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
|
||||
44
docs/requirement/addin-workflow.md
Normal file
44
docs/requirement/addin-workflow.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# ขั้นตอนการทำงานของ Addin
|
||||
|
||||
## 1. เมื่อเปิดเอกสาร
|
||||
|
||||
- อ่าน custom property ของ word
|
||||
1. ticket_data ซึ่งจะมีข้อมูลเบื้องต้น ดังนี้
|
||||
```jsonc
|
||||
{
|
||||
"exp": 1793067433,
|
||||
"fid": "T12_FD155_3s-long_ZZZ",
|
||||
"tid": "T12",
|
||||
"uid": "oom",
|
||||
"uname": "สรวิชญ์",
|
||||
}
|
||||
```
|
||||
2. นำค่า ticket_data ที่เป็น string ก่อนนำไปแปลง json ทำการ sign HMAC-SHA256 โดยใช้ secret "super_secret" ซึ่งต้องรองรับการ config ค่านี้ในอนาคต
|
||||
3. นำค่าที่ sign ได้ มาเทียบกับค่า property "HmacSha256"
|
||||
- ขั้นตอนการทำ authen
|
||||
```
|
||||
ขั้นตอน
|
||||
1. เมื่อเปิดไฟล์ Word ให้อ่าน Custom Property 2 ค่าที่ฝังมาในไฟล์ คือ value จริง และ Hash Value (HMAC) เพื่อตรวจสอบว่าไฟล์สร้างผ่านระบบอย่างถูกต้อง
|
||||
2. ถ้า HMAC ไม่ตรงกัน แสดงว่าไฟล์ถูกแก้ไข ให้ Add-in disable การทำงานทั้งหมด
|
||||
3. ถ้า HMAC ตรงกัน ให้เก็บค่า uId, uName, fId, tid ไว้ แล้วตรวจสอบว่า userId นี้มีใน localStorage อยู่แล้วหรือไม่
|
||||
4. ถ้ายังไม่มีให้ตรวจค่า Exp ที่ได้จากไฟล์ Word
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
- ยังไม่หมดอายุ → เก็บใน localStorage
|
||||
- userName: ค่าจาก uName
|
||||
- คีย์คือ fid: เอาค่า {
|
||||
"userid": uid,
|
||||
"username": uname,
|
||||
"fid": fid (เป็น string),
|
||||
"tid": tid
|
||||
}
|
||||
ไปทำการขอ token ที่ /api/documents/token แล้วนำค่าที่ได้มาเก็บไว้ที่ตัวแปรนี้ เพื่อให้อ่านค่าไปใช้ให้ตรงกับค่า fid ที่ได้รับ เนื่องจาก api ในส่วน /documents จะต้องแนบ token ไปไม่ส่งค่าไปตรงๆ เพื่อความถูกต้อง
|
||||
5. ถ้ามี Session อยู่แล้ว ให้ตรวจว่า userId ตรงกับ Session หรือไม่
|
||||
6. ถ้า userId ไม่ตรงกัน (user ใหม่) ให้ตรวจค่า Expire
|
||||
- ยังไม่หมดอายุ → เคลียร์ Session เดิม แล้วสร้าง Session 2 ค่าใหม่
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
7. ถ้า userId ตรงกัน (คนเดิม) ให้ตรวจว่า fid มีใน localStorage แล้วหรือไม่
|
||||
- มีแล้ว (กลับมาทำเอกสารเดิม) → ผ่านได้เลย ไม่ต้องตรวจ Expire
|
||||
- ยังไม่มี (เอกสารใหม่) → ตรวจค่า Expire
|
||||
- ยังไม่หมดอายุ → ทำการขอ token แล้วเก็บใน localStorage ตามขั้นตอนก่อนหน้า
|
||||
- หมดอายุ → disable Add-in ทั้งหมด
|
||||
```
|
||||
29
drizzle/0005_icy_bloodaxe.sql
Normal file
29
drizzle/0005_icy_bloodaxe.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CREATE TABLE "document_versions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"version_number" integer NOT NULL,
|
||||
"client_version_id" text,
|
||||
"content_hash" text NOT NULL,
|
||||
"message" text,
|
||||
"fields" jsonb,
|
||||
"template_id" text,
|
||||
"file_name" text,
|
||||
"docx_object_key" text NOT NULL,
|
||||
"pdf_object_key" text,
|
||||
"docx_size" integer NOT NULL,
|
||||
"pdf_size" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"user_name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"doc_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_versions_document_number_key" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint
|
||||
CREATE INDEX "document_versions_document_created_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "documents_doc_id_key" ON "documents" USING btree ("doc_id");
|
||||
735
drizzle/meta/0005_snapshot.json
Normal file
735
drizzle/meta/0005_snapshot.json
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
{
|
||||
"id": "d9645a8e-eaf0-4f76-8c5e-bf978f6e61f0",
|
||||
"prevId": "84f979ad-4a95-41f5-8082-30a994f8644d",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.annotation_replies": {
|
||||
"name": "annotation_replies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_replies_annotation_idx": {
|
||||
"name": "annotation_replies_annotation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "annotation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_replies_annotation_id_annotations_id_fk": {
|
||||
"name": "annotation_replies_annotation_id_annotations_id_fk",
|
||||
"tableFrom": "annotation_replies",
|
||||
"tableTo": "annotations",
|
||||
"columnsFrom": [
|
||||
"annotation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotation_versions": {
|
||||
"name": "annotation_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_versions_file_idx": {
|
||||
"name": "annotation_versions_file_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_versions_file_id_files_id_fk": {
|
||||
"name": "annotation_versions_file_id_files_id_fk",
|
||||
"tableFrom": "annotation_versions",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotations": {
|
||||
"name": "annotations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved": {
|
||||
"name": "resolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotations_file_page_idx": {
|
||||
"name": "annotations_file_page_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "page_index",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotations_file_id_files_id_fk": {
|
||||
"name": "annotations_file_id_files_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.bookmarks": {
|
||||
"name": "bookmarks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookmarks_file_id_files_id_fk": {
|
||||
"name": "bookmarks_file_id_files_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.document_versions": {
|
||||
"name": "document_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"document_id": {
|
||||
"name": "document_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_number": {
|
||||
"name": "version_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_version_id": {
|
||||
"name": "client_version_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_hash": {
|
||||
"name": "content_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"fields": {
|
||||
"name": "fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"template_id": {
|
||||
"name": "template_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_name": {
|
||||
"name": "file_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_object_key": {
|
||||
"name": "docx_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_object_key": {
|
||||
"name": "pdf_object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"docx_size": {
|
||||
"name": "docx_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pdf_size": {
|
||||
"name": "pdf_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"document_versions_document_number_key": {
|
||||
"name": "document_versions_document_number_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "version_number",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"document_versions_document_created_idx": {
|
||||
"name": "document_versions_document_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"document_versions_document_id_documents_id_fk": {
|
||||
"name": "document_versions_document_id_documents_id_fk",
|
||||
"tableFrom": "document_versions",
|
||||
"tableTo": "documents",
|
||||
"columnsFrom": [
|
||||
"document_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"doc_id": {
|
||||
"name": "doc_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"documents_doc_id_key": {
|
||||
"name": "documents_doc_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "doc_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.files": {
|
||||
"name": "files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_url": {
|
||||
"name": "pdf_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type_id": {
|
||||
"name": "type_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"files_type_id_idx": {
|
||||
"name": "files_type_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"files_external_id_key": {
|
||||
"name": "files_external_id_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "external_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.annotation_type": {
|
||||
"name": "annotation_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"HIGHLIGHT",
|
||||
"DRAW",
|
||||
"COMMENT",
|
||||
"SHAPE",
|
||||
"REDACTION",
|
||||
"TEXTBOX",
|
||||
"STAMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@
|
|||
"when": 1790072946313,
|
||||
"tag": "0004_right_ted_forrester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792467537332,
|
||||
"tag": "0005_icy_bloodaxe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import type { AnnotateEvent } from "@/lib/annotate-events";
|
|||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// SSE sync (PLAN.md §4, rules/realtime.md)
|
||||
// - เปิด EventSource ไป /api/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||
// - เปิด EventSource ไป /api/note/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
|
||||
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export function useAnnotationRealtime(
|
|||
const [replies, setReplies] = useState(initialReplies);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`));
|
||||
const es = new EventSource(withBasePath(`/api/note/annotations/stream?fileId=${fileId}`));
|
||||
|
||||
es.onmessage = (e: MessageEvent<string>) => {
|
||||
let ev: AnnotateEvent;
|
||||
|
|
|
|||
|
|
@ -23,8 +23,24 @@ const CONTAINER_CLASS = "pdf-custom-select";
|
|||
* ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */
|
||||
const MOVE_THRESHOLD_PX = 8;
|
||||
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
|
||||
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
|
||||
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) — ค่านี้เป็น "เพดาน" เท่านั้น:
|
||||
* offset จริงของแต่ละ gesture ถูก cap ด้วยความสูงบรรทัดผ่าน fingerOffsetFor (บรรทัด
|
||||
* PDF บน tablet zoom-fit สูงแค่ ~14px — หักตายตัว 12px ทำ probe หลุดขอบบนบรรทัด
|
||||
* ไปโดน heading/บรรทัดอื่น จนลากแนวนอนบรรทัดเดียวแล้วเงียบไม่สร้างมาร์ก — รายงาน
|
||||
* 2026-09-28) */
|
||||
const FINGER_OFFSET_PX = 12;
|
||||
|
||||
/** offset ของ gesture นี้ = min(12, ครึ่งความสูงบรรทัด − 2) วัดจาก span ของ anchor
|
||||
* ที่ resolve ได้ (span ของ textLayer สูงเท่าบรรทัด) — บรรทัดใหญ่ (≥28px) ได้ 12 เท่า
|
||||
* เดิม, บรรทัดเตี้ย 14px ได้ 5px จนยังอยู่ในบรรทัด; วัดไม่ได้ = 12 ตามเดิม */
|
||||
function fingerOffsetFor(caret: Range): number {
|
||||
const node = caret.startContainer;
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
const span = el?.closest(".textLayer span");
|
||||
const h = span?.getBoundingClientRect().height ?? 0;
|
||||
if (!(h > 4)) return FINGER_OFFSET_PX;
|
||||
return Math.min(FINGER_OFFSET_PX, Math.max(2, h / 2 - 2));
|
||||
}
|
||||
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */
|
||||
const AUTOSCROLL_MARGIN_PX = 60;
|
||||
const AUTOSCROLL_MAX_SPEED_PX = 12;
|
||||
|
|
@ -75,11 +91,23 @@ function findFirstTextNode(node: Node): Node | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/** ทิศ retry ของ resolveTextCaret — รอยต่อระหว่าง span บางกว่า 2–3px มักพอ */
|
||||
const RETRY_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, -2], [0, 2], [-2, 0], [2, 0],
|
||||
[0, -3], [0, 3], [-3, 0], [3, 0],
|
||||
];
|
||||
|
||||
/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่
|
||||
* startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding
|
||||
* ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail
|
||||
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1) */
|
||||
function resolveTextCaret(x: number, y: number): Range | null {
|
||||
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1)
|
||||
* maxRetries: ระหว่างลากส่งค่าน้อย (2) — fail แล้วมีทางออกอยู่แล้วคือคง
|
||||
* lastFocus ของเฟรมก่อน ไม่ควรเสีย ~6ms/เฟรม retry ครบ 8 ทิศเหมือนตอนกด (B1) */
|
||||
function resolveTextCaret(
|
||||
x: number,
|
||||
y: number,
|
||||
maxRetries = RETRY_OFFSETS.length,
|
||||
): Range | null {
|
||||
const first = caretFromPoint(x, y);
|
||||
const fromElement = (r: Range | null): Range | null => {
|
||||
if (!r) return null;
|
||||
|
|
@ -93,6 +121,9 @@ function resolveTextCaret(x: number, y: number): Range | null {
|
|||
const textNode =
|
||||
child?.nodeType === Node.TEXT_NODE ? child : findFirstTextNode(child ?? el);
|
||||
if (!textNode) return null;
|
||||
// container ใหญ่จน DFS หลุดออกนอก textLayer (เช่นตก page div) — text node
|
||||
// แรกของหน้าไม่ใช่ข้อความที่จุดนั้น ทิ้งดีกว่าเด้งไปมุมบนซ้าย (A6)
|
||||
if (!textNode.parentElement?.closest(".textLayer")) return null;
|
||||
const nr = document.createRange();
|
||||
nr.setStart(textNode, 0);
|
||||
nr.collapse(true);
|
||||
|
|
@ -102,12 +133,8 @@ function resolveTextCaret(x: number, y: number): Range | null {
|
|||
const resolved = fromElement(first);
|
||||
if (resolved) return resolved;
|
||||
|
||||
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม (รอยต่อบางกว่า 2-3px มักพอ)
|
||||
const offsets: Array<[number, number]> = [
|
||||
[0, -2], [0, 2], [-2, 0], [2, 0],
|
||||
[0, -3], [0, 3], [-3, 0], [3, 0],
|
||||
];
|
||||
for (const [dx, dy] of offsets) {
|
||||
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม
|
||||
for (const [dx, dy] of RETRY_OFFSETS.slice(0, maxRetries)) {
|
||||
const r = fromElement(caretFromPoint(x + dx, y + dy));
|
||||
if (r) return r;
|
||||
}
|
||||
|
|
@ -118,17 +145,32 @@ function resolveTextCaret(x: number, y: number): Range | null {
|
|||
* ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */
|
||||
const NEAREST_SPAN_MAX_PX = 40;
|
||||
|
||||
/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่
|
||||
* เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */
|
||||
type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null;
|
||||
/** รายการ span ของหน้าพร้อมพิกัดแบบสัดส่วน 0–1 ของหน้า — วัดครั้งเดียวต่อ gesture
|
||||
* (getBoundingClientRect ทั้งหน้า ~3,000 span แพง ~2.3ms ต่อครั้ง) เก็บเป็น
|
||||
* fraction แบบเดียวกับ anchorRel: ทนทั้งการเลื่อนหน้าระหว่างลาก และ re-fit
|
||||
* ความกว้างหน้ากลาง gesture (นิ้วที่สอง toggle sidebar — code review 2.3
|
||||
* 2026-09-28) โดยบวก rect ปัจจุบันของหน้าคืนตอนใช้ (B2) */
|
||||
type SpanItem = {
|
||||
el: Element;
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
type SpanCache = { pageEl: HTMLElement; items: SpanItem[] } | null;
|
||||
|
||||
/** caret ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ
|
||||
* snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */
|
||||
function caretAtSpanStart(span: Element): Range | null {
|
||||
/** caret ใน span ที่ตำแหน่งสัดส่วน x (0–1) ของความกว้าง span — ชั้น 3–4 ของ
|
||||
* resolver เดิมวางที่ต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว) จน
|
||||
* anchor/focus เด้งไปต้นบรรทัดแทนตำแหน่งนิ้ว; ประมาณ offset จากสัดส่วนตัวอักษร
|
||||
* (ฟอนต์ไม่ monospace คลาดได้เล็กน้อย — snapCaretToWord ปรับลงขอบคำให้)
|
||||
* len−1 กัน offset ตกท้าย text node ที่ word snap ไม่ขยาย (A5) */
|
||||
function caretInSpanAt(span: Element, ratio: number): Range | null {
|
||||
const t = findFirstTextNode(span);
|
||||
if (!t) return null;
|
||||
const len = t?.textContent?.length ?? 0;
|
||||
if (!t || len === 0) return null;
|
||||
const off = Math.min(len - 1, Math.max(0, Math.round(ratio * len)));
|
||||
const r = document.createRange();
|
||||
r.setStart(t, 0);
|
||||
r.setStart(t, off);
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
|
|
@ -143,7 +185,8 @@ function spanFromPointStack(x: number, y: number): Range | null {
|
|||
// เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ —
|
||||
// จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป)
|
||||
if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue;
|
||||
const caret = caretAtSpanStart(el);
|
||||
const rc = el.getBoundingClientRect();
|
||||
const caret = caretInSpanAt(el, rc.width > 0 ? (x - rc.left) / rc.width : 0);
|
||||
if (caret) return caret;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -161,28 +204,43 @@ function nearestSpanCaret(
|
|||
const pageEl =
|
||||
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-page]") ?? null;
|
||||
if (!pageEl) return null;
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) {
|
||||
cache.spanCache = {
|
||||
pageEl,
|
||||
spans: Array.from(pageEl.querySelectorAll(".textLayer span")),
|
||||
};
|
||||
const items: SpanItem[] = [];
|
||||
for (const el of Array.from(pageEl.querySelectorAll(".textLayer span"))) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
items.push({
|
||||
el,
|
||||
top: (r.top - pr.top) / pr.height,
|
||||
bottom: (r.bottom - pr.top) / pr.height,
|
||||
left: (r.left - pr.left) / pr.width,
|
||||
right: (r.right - pr.left) / pr.width,
|
||||
});
|
||||
}
|
||||
let best: Element | null = null;
|
||||
cache.spanCache = { pageEl, items };
|
||||
}
|
||||
// จุดเป็น fraction เดียวกับ cache — pr อ่านใหม่ทุกครั้งจึงตามทั้งการเลื่อนหน้า
|
||||
// และ re-fit ความกว้าง; รัศมีค้นหา (px) แปลงเป็น fraction ของความสูงหน้าปัจจุบัน
|
||||
const fx = (x - pr.left) / pr.width;
|
||||
const fy = (y - pr.top) / pr.height;
|
||||
const maxDy = NEAREST_SPAN_MAX_PX / pr.height;
|
||||
let best: SpanItem | null = null;
|
||||
let bestDy = Infinity;
|
||||
let bestDx = Infinity;
|
||||
for (const s of cache.spanCache.spans) {
|
||||
const r = s.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
const dy = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0;
|
||||
if (dy > NEAREST_SPAN_MAX_PX) continue;
|
||||
const dx = Math.abs(r.left + r.width / 2 - x);
|
||||
for (const it of cache.spanCache.items) {
|
||||
const dy = fy < it.top ? it.top - fy : fy > it.bottom ? fy - it.bottom : 0;
|
||||
if (dy > maxDy) continue;
|
||||
const dx = Math.abs((it.left + it.right) / 2 - fx);
|
||||
if (dy < bestDy || (dy === bestDy && dx < bestDx)) {
|
||||
best = s;
|
||||
best = it;
|
||||
bestDy = dy;
|
||||
bestDx = dx;
|
||||
}
|
||||
}
|
||||
return best ? caretAtSpanStart(best) : null;
|
||||
if (!best) return null;
|
||||
const w = best.right - best.left;
|
||||
return caretInSpanAt(best.el, w > 0 ? (fx - best.left) / w : 0);
|
||||
}
|
||||
|
||||
/** resolveTextCaret ครบทั้ง 4 ชั้น (เรียงตามต้นทุน): caret API + ขยับ ±3px →
|
||||
|
|
@ -216,7 +274,8 @@ function getSegmenter(): Intl.Segmenter | null {
|
|||
* กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ
|
||||
* ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน
|
||||
* หรือไม่ใช่ text node = ไม่ขยาย */
|
||||
function snapCaretToWord(caret: Range): void {
|
||||
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
|
||||
export function snapCaretToWord(caret: Range): void {
|
||||
const node = caret.startContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||
const text = node.textContent ?? "";
|
||||
|
|
@ -244,16 +303,21 @@ function snapCaretToWord(caret: Range): void {
|
|||
}
|
||||
|
||||
/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ
|
||||
* tree จน compare ไม่ได้ */
|
||||
function rangeBetween(a: Range, b: Range): Range | null {
|
||||
* tree จน compare ไม่ได้
|
||||
* ฝั่งหลัง (b หรือ a ตามทิศ) ต้องใช้ boundary **end** ไม่ใช่ start: anchor/focus
|
||||
* ผ่าน snapCaretToWord แล้วเป็น range ครอบคำ (ไม่ collapsed) การอ่าน start จะตัด
|
||||
* คำท้ายทิ้ง (ลาก alpha→gamma ได้แค่ alpha beta) และลากในคำเดียว (a==b) จะได้
|
||||
* selection ว่าง — bug report A1 2026-09-28 */
|
||||
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
|
||||
export function rangeBetween(a: Range, b: Range): Range | null {
|
||||
const r = document.createRange();
|
||||
try {
|
||||
if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) {
|
||||
r.setStart(a.startContainer, a.startOffset);
|
||||
r.setEnd(b.startContainer, b.startOffset);
|
||||
r.setEnd(b.endContainer, b.endOffset);
|
||||
} else {
|
||||
r.setStart(b.startContainer, b.startOffset);
|
||||
r.setEnd(a.startContainer, a.startOffset);
|
||||
r.setEnd(a.endContainer, a.endOffset);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -312,16 +376,24 @@ function clampToAnchorPage(
|
|||
type DragState = {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
/** ระยะหักขึ้นของจุด probe (เฉพาะ touch — กันนิ้วบังข้อความ) คำนวณครั้งเดียวตอน
|
||||
* pointerdown จากความสูงบรรทัดของ anchor (fingerOffsetFor) ใช้ทั้ง probe
|
||||
* ระหว่างลากและเส้นทางกรอง rect; เมาส์/สไตลัส = 0 */
|
||||
fingerOffset: number;
|
||||
/** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */
|
||||
anchorPt: ClientPt;
|
||||
/** ตำแหน่ง pointer ล่าสุด */
|
||||
curPt: ClientPt;
|
||||
/** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */
|
||||
anchor: Range;
|
||||
/** จุด probe ที่ resolve anchor ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด
|
||||
* ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง
|
||||
* (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */
|
||||
anchorPathPt: ClientPt;
|
||||
/** จุด probe ที่ resolve anchor ได้ เก็บเป็นสัดส่วน 0–1 ของหน้า anchor (จอสัมผัส =
|
||||
* จุด y−offset หรือจุดแตะจริงลูกใดลูกหนึ่ง) — แปลงกลับเป็นพิกัดจอใหม่ทุกเฟรม
|
||||
* เพื่อเป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง (ใช้พิกัดนิ้ว
|
||||
* raw ตรง ๆ จะคลาดเท่า offset จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨;
|
||||
* เก็บเป็นสัดส่วนเพราะพิกัด client คงที่จะโกหกทันทีที่ auto-scroll เลื่อนเนื้อหา
|
||||
* กลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก — bug report A2
|
||||
* 2026-09-28) */
|
||||
anchorRel: { x: number; y: number };
|
||||
/** หน้าของ anchor — เก็บตั้งแต่ pointerdown (anchor ไม่ย้ายหน้าระหว่างลาก)
|
||||
* ใช้ clamp ตอนลากล้ำเข้าหน้าถัดไป (จุดที่ 3 ของ bug report) */
|
||||
anchorPageEl: HTMLElement;
|
||||
|
|
@ -402,17 +474,19 @@ export function useCustomSelection({
|
|||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0);
|
||||
const y = d.curPt.y - d.fingerOffset;
|
||||
// ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง /
|
||||
// ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan
|
||||
// ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย
|
||||
// แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง)
|
||||
let focus = resolveTextCaret(d.curPt.x, y);
|
||||
let focus = resolveTextCaret(d.curPt.x, y, 2);
|
||||
if (!focus) {
|
||||
d.failStreak++;
|
||||
// fail ต่อเนื่องเกิน 3 frame = ลองชั้นแพงกว่าก่อนยอม fallback ไปตำแหน่งเดิม
|
||||
// (ส่ง d ตรง ๆ ให้ cache ที่ชั้นแพงสร้างถูกเก็บกลับที่ gesture ใช้ต่อ — เดิม
|
||||
// ห่อ object ใหม่ทุกครั้ง cache จึงไม่เคยติด A7.5)
|
||||
if (d.failStreak > 3 && d.failStreak % 5 === 0) {
|
||||
focus = resolveTextCaretDeep(d.curPt.x, y, { spanCache: d.spanCache });
|
||||
focus = resolveTextCaretDeep(d.curPt.x, y, d);
|
||||
}
|
||||
}
|
||||
if (focus) {
|
||||
|
|
@ -431,7 +505,16 @@ export function useCustomSelection({
|
|||
}
|
||||
snapCaretToWord(focus);
|
||||
let range = rangeBetween(d.anchor, focus);
|
||||
let pageEl = range && !range.collapsed ? pageOfRange(range) : null;
|
||||
if (range && range.collapsed) {
|
||||
// ผู้ใช้ย่อ selection จนว่างจริง (anchor==focus) — เคลียร์ payload ให้
|
||||
// commit ตรงกับ preview เสมอ: ปล่อยนิ้วตอนนี้ = ไม่สร้างมาร์ก ต่างจาก
|
||||
// frame ที่ rectsInSweptPath กรองว่าง "ชั่วคราว" ซึ่งยังคง lastRef ตาม
|
||||
// เจตนาเดิมของ bug report จุดที่ 2 (A4)
|
||||
lastRef.current = null;
|
||||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
let pageEl = range ? pageOfRange(range) : null;
|
||||
// ลากล้ำเข้าหน้าอื่น (คร่อม 2 หน้า หรือ focus ข้ามไปหน้าถัดไปแล้ว) — clamp
|
||||
// ฝั่ง focus ไว้ที่ท้าย/ต้นหน้าของ anchor แทนการทิ้ง selection ทั้งเส้น
|
||||
// (จุดที่ 3 ของ bug report — ข้ามเมื่อ range collapsed: anchor==focus ใน
|
||||
|
|
@ -448,18 +531,24 @@ export function useCustomSelection({
|
|||
return;
|
||||
}
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
|
||||
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ
|
||||
// จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน
|
||||
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จอสัมผัส = จุดที่
|
||||
// หัก fingerOffset แล้ว) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาดจนบรรทัดเป้าหมายโดน
|
||||
// lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก
|
||||
// เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨)
|
||||
const pathEnd: ClientPt =
|
||||
d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt;
|
||||
const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd);
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
// ต้นเส้นทางแปลงจากสัดส่วนของหน้าใหม่ทุกเฟรม (A2) — anchor ต้องเลื่อนตาม
|
||||
// เนื้อหาตอน auto-scroll ไม่ใช่ค้างที่พิกัดจอตอนกดนิ้ว
|
||||
const anchorNow: ClientPt = {
|
||||
x: pr.left + d.anchorRel.x * pr.width,
|
||||
y: pr.top + d.anchorRel.y * pr.height,
|
||||
};
|
||||
const kept = rectsInSweptPath(rects, anchorNow, pathEnd);
|
||||
if (kept.length === 0) {
|
||||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
const rectsPct = mergeRectsByLine(
|
||||
kept.map((r) =>
|
||||
rectToPercent(
|
||||
|
|
@ -475,7 +564,7 @@ export function useCustomSelection({
|
|||
);
|
||||
lastRef.current = {
|
||||
range: range.cloneRange(),
|
||||
start: d.anchorPathPt,
|
||||
start: anchorNow,
|
||||
end: pathEnd,
|
||||
};
|
||||
const pageIndex = Number(pageEl.dataset.page);
|
||||
|
|
@ -519,7 +608,10 @@ export function useCustomSelection({
|
|||
Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_PX,
|
||||
// cap ระยะเลยขอบที่ margin — นิ้วอยู่นอกพื้นที่เอกสารไกล ๆ ต้องไม่
|
||||
// เร็วกว่า AUTOSCROLL_MAX_SPEED_PX (A7.1)
|
||||
(Math.min(into, AUTOSCROLL_MARGIN_PX) / AUTOSCROLL_MARGIN_PX) *
|
||||
AUTOSCROLL_MAX_SPEED_PX,
|
||||
),
|
||||
);
|
||||
let dx = 0;
|
||||
|
|
@ -543,9 +635,9 @@ export function useCustomSelection({
|
|||
scrollRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const seenMovePointerIds = new Set<number>();
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
// อุปกรณ์ hybrid (จอสัมผัส + เมาส์): คลิกขวา/กลางไม่ใช่การเริ่มเลือกข้อความ (A7.4)
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
if (dragRef.current) {
|
||||
// นิ้วที่สองระหว่างลาก — ไม่สน รวมถึง touch ปลอมจาก palm rejection (มือ
|
||||
// ที่พักบนจอระหว่างลากด้วย pen) — ห้ามให้ pointer ตัวใหม่แทนที่ drag เดิม
|
||||
|
|
@ -580,16 +672,38 @@ export function useCustomSelection({
|
|||
// ธรรมดา ไม่เริ่ม selection (click ของ annotation/pin ทำงานตามเดิม)
|
||||
return;
|
||||
}
|
||||
// offset จริงของ gesture นี้จากความสูงบรรทัดของ anchor ที่เพิ่ง resolve ได้ —
|
||||
// บรรทัดเตี้ยกว่านิ้ว (span ที่ probe ที่ −12 โดนสูง < 28px) ให้หักน้อยลงแล้ว
|
||||
// resolve anchor ใหม่ที่จุดที่หักจริง เพื่อไม่ให้ anchor ค้างบรรทัดที่ probe
|
||||
// เต็มพลาดไปโดน; resolve ใหม่ไม่ได้ = ใช้ anchor เดิมต่อ — ขอบเขต: ถ้า probe
|
||||
// ที่ −12 โดน span สูง ≥ 28px (เช่น heading ใหญ่เหนือบรรทัดเป้าหมาย) offset
|
||||
// ยังเป็น 12 จึงไม่ re-resolve — anchor ค้าง heading ตาม heuristic เดิม
|
||||
// (code review 2.2 2026-09-28: comment เดิมอ้างว่าแก้เคส heading — เกินจริง)
|
||||
const fingerOffset =
|
||||
e.pointerType === "touch" ? fingerOffsetFor(caret) : 0;
|
||||
if (fingerOffset < FINGER_OFFSET_PX) {
|
||||
const adjustedY = e.clientY - fingerOffset;
|
||||
const re = resolveTextCaretDeep(e.clientX, adjustedY, cache);
|
||||
if (re) {
|
||||
caret = re;
|
||||
resolvedY = adjustedY;
|
||||
}
|
||||
}
|
||||
const anchorPageEl = pageOfRange(caret);
|
||||
if (!anchorPageEl) return;
|
||||
const anchorPr = anchorPageEl.getBoundingClientRect();
|
||||
// gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report:
|
||||
// lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น)
|
||||
lastRef.current = null;
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
pointerType: e.pointerType,
|
||||
fingerOffset,
|
||||
anchorPt: { x: e.clientX, y: e.clientY },
|
||||
anchorPathPt: { x: e.clientX, y: resolvedY },
|
||||
anchorRel: {
|
||||
x: (e.clientX - anchorPr.left) / anchorPr.width,
|
||||
y: (resolvedY - anchorPr.top) / anchorPr.height,
|
||||
},
|
||||
curPt: { x: e.clientX, y: e.clientY },
|
||||
anchor: caret,
|
||||
anchorPageEl,
|
||||
|
|
@ -600,14 +714,13 @@ export function useCustomSelection({
|
|||
anchorSnapped: false,
|
||||
active: false,
|
||||
};
|
||||
// arm ตั้งแต่ pointerdown ไม่ใช่ตอนเลย threshold — tap ที่ไม่ขยับ (ไม่
|
||||
// active) ก็ต้องมีตาข่ายรับ pointerup ของตัวเอง ไม่งั้น dragRef ค้างจน
|
||||
// gesture ถัดไปเงียบหมด (เจอจริงหลังย้าย listener ออกจาก el — e2e 2026-09-28)
|
||||
armDocUpListeners();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
// pen/touch ยิง 60–120Hz ถ้า log ทุก event จะท่วม console และหน่วงเครื่อง
|
||||
// จนเปลี่ยนพฤติกรรมที่กำลังวินิจฉัยอยู่เอง
|
||||
if (!seenMovePointerIds.has(e.pointerId)) {
|
||||
seenMovePointerIds.add(e.pointerId);
|
||||
}
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.pointerId) return;
|
||||
d.curPt = { x: e.clientX, y: e.clientY };
|
||||
|
|
@ -628,11 +741,31 @@ export function useCustomSelection({
|
|||
schedulePreview();
|
||||
};
|
||||
|
||||
const onUp = (e: PointerEvent) => finish(e, true);
|
||||
const onCancel = (e: PointerEvent) => finish(e, false);
|
||||
|
||||
/** ตาข่ายรับ pointerup/pointercancel ระดับ document (capture phase) — ผูกตั้งแต่
|
||||
* pointerdown ที่เริ่ม gesture จนถึง finish (รวม tap ที่ไม่ activate):
|
||||
* Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง (lostpointercapture
|
||||
* ยิงทั้งที่ยังไม่ปล่อยนิ้ว — เห็นจริงใน e2e 2026-09-28) เมื่อ capture หาด
|
||||
* pointerup ยิงที่ element ใต้นิ้วจริง ซึ่งอาจอยู่นอก <main> (header/sidebar
|
||||
* ตอน auto-scroll ดันนิ้วค้างใกล้ขอบ) — ผูกที่ el อย่างเดียวจะไม่ได้ยิน จน
|
||||
* dragRef ค้างและ gesture ถัดไปทุกตัวเงียบหมด (code review 2.1 2026-09-28) */
|
||||
const armDocUpListeners = () => {
|
||||
document.addEventListener("pointerup", onUp, true);
|
||||
document.addEventListener("pointercancel", onCancel, true);
|
||||
};
|
||||
const disarmDocUpListeners = () => {
|
||||
document.removeEventListener("pointerup", onUp, true);
|
||||
document.removeEventListener("pointercancel", onCancel, true);
|
||||
};
|
||||
|
||||
/** เคลียร์ state ทุกทาง — commit เฉพาะตอน pointerup ที่มี range ไม่ collapsed
|
||||
* (pointercancel จาก gesture ของระบบ = ทิ้ง selection ไม่ commit) */
|
||||
const finish = (e: PointerEvent, commit: boolean) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.pointerId) return;
|
||||
disarmDocUpListeners();
|
||||
stopAutoScroll();
|
||||
stopPreviewRaf();
|
||||
if (d.active) {
|
||||
|
|
@ -649,17 +782,19 @@ export function useCustomSelection({
|
|||
|
||||
el.addEventListener("pointerdown", onPointerDown);
|
||||
el.addEventListener("pointermove", onPointerMove);
|
||||
const onUp = (e: PointerEvent) => finish(e, true);
|
||||
const onCancel = (e: PointerEvent) => finish(e, false);
|
||||
el.addEventListener("pointerup", onUp);
|
||||
el.addEventListener("pointercancel", onCancel);
|
||||
// pointerup/pointercancel ไม่ผูกที่ el — ใช้ตาข่าย document capture phase ต่อ
|
||||
// ช่วง active (armDocUpListeners ด้านบน) เพื่อรอดเคส capture หลุดกลาง drag
|
||||
// ห้ามผูก finish เข้า lostpointercapture: กับ touch ของ Chromium event นี้ยิง
|
||||
// "กลาง drag" ได้เอง (การ re-hit-test ของ touch pointer ที่ implicit capture
|
||||
// อยู่ที่ span ตั้งแต่ pointerdown แล้วถูกย้ายมา container ตอน setPointerCapture)
|
||||
// ผูกแล้ว dragRef ถูกเคลียร์กลางทางจนทุก gesture เงียบหมด — ยืนยันด้วย
|
||||
// Playwright + CDP touch แล้ว 2026-09-28 (A7.3 ของรายงาน review — ไม่ใช้)
|
||||
|
||||
return () => {
|
||||
el.classList.remove(CONTAINER_CLASS);
|
||||
el.removeEventListener("pointerdown", onPointerDown);
|
||||
el.removeEventListener("pointermove", onPointerMove);
|
||||
el.removeEventListener("pointerup", onUp);
|
||||
el.removeEventListener("pointercancel", onCancel);
|
||||
disarmDocUpListeners();
|
||||
stopAutoScroll();
|
||||
stopPreviewRaf();
|
||||
dragRef.current = null;
|
||||
|
|
|
|||
124
lib/addin-ticket.ts
Normal file
124
lib/addin-ticket.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ
|
||||
// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้
|
||||
// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server
|
||||
// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court
|
||||
// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256
|
||||
// ของกลุ่ม template: hex lowercase บน string ดิบ)
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน
|
||||
// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts)
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import {
|
||||
isTicketExpired,
|
||||
normalizeHexDigest,
|
||||
parseDocverTicket,
|
||||
type DocverTicket,
|
||||
} from "@/utils/docver-ticket";
|
||||
import type { DocverTokenClaims } from "@/lib/docver-token";
|
||||
|
||||
/**
|
||||
* secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load
|
||||
* ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp
|
||||
* ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret"
|
||||
* (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ
|
||||
* ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ
|
||||
*/
|
||||
export function addinTicketSecret(): string {
|
||||
return (
|
||||
process.env.ADDIN_TICKET_SECRET?.trim() ||
|
||||
process.env.SECRET?.trim() ||
|
||||
"super_secret"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
|
||||
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
|
||||
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
|
||||
*/
|
||||
export function verifyAddinTicket(
|
||||
ticketData: string,
|
||||
hmacHex: string,
|
||||
secret: string,
|
||||
nowSec: number = Math.floor(Date.now() / 1000),
|
||||
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
||||
const digest = normalizeHexDigest(hmacHex);
|
||||
if (digest === null) return { ok: false };
|
||||
|
||||
// เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง
|
||||
// ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw)
|
||||
const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest();
|
||||
const expectedHash = createHash("sha256").update(expected).digest();
|
||||
const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest();
|
||||
if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false };
|
||||
|
||||
const parsed = parseDocverTicket(ticketData);
|
||||
if (!parsed.ok) return { ok: false };
|
||||
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
|
||||
return { ok: true, ticket: parsed.ticket };
|
||||
}
|
||||
|
||||
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
|
||||
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
||||
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
||||
}
|
||||
|
||||
/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */
|
||||
const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const;
|
||||
|
||||
export type TicketTokenBodyResult =
|
||||
| { ok: true; ticketData: string; hmacHex: string }
|
||||
| { ok: false; kind: "missing" | "invalid"; error: string };
|
||||
|
||||
/**
|
||||
* parse body ของ POST /api/documents/token ทางเข้า ticket
|
||||
* - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน)
|
||||
* - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ)
|
||||
*/
|
||||
export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "invalid",
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => {
|
||||
const value = record[name];
|
||||
if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing
|
||||
if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid
|
||||
return value;
|
||||
};
|
||||
|
||||
const ticketData = read("ticket_data");
|
||||
if (ticketData === null) {
|
||||
return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" };
|
||||
}
|
||||
if (ticketData === "") {
|
||||
return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" };
|
||||
}
|
||||
if (ticketData.length > 2048) {
|
||||
return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" };
|
||||
}
|
||||
|
||||
const hmacHex = read("HmacSha256");
|
||||
if (hmacHex === null) {
|
||||
return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" };
|
||||
}
|
||||
if (hmacHex === "") {
|
||||
return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" };
|
||||
}
|
||||
if (hmacHex.length > 128) {
|
||||
return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" };
|
||||
}
|
||||
|
||||
return { ok: true, ticketData, hmacHex };
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
||||
|
||||
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
|
||||
// → SSE route (app/api/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
||||
// → SSE route (app/api/note/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
||||
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
|
||||
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling
|
||||
|
||||
|
|
|
|||
227
lib/document-versioning.ts
Normal file
227
lib/document-versioning.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
|
||||
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
|
||||
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
|
||||
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||
|
||||
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
|
||||
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
|
||||
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
|
||||
export const DOCVER_MESSAGE_MAX = 2048;
|
||||
|
||||
export const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
|
||||
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
|
||||
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
const FORM_FIELDS = [
|
||||
"file",
|
||||
"pdf",
|
||||
"clientVersionId",
|
||||
"fields",
|
||||
"message",
|
||||
"fileName",
|
||||
] as const;
|
||||
|
||||
export type DocverError = { ok: false; error: string };
|
||||
|
||||
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
|
||||
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
|
||||
export function docverError(message: string, status: number): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
|
||||
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
|
||||
export function isDocxBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
|
||||
}
|
||||
|
||||
export function isPdfBytes(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
||||
}
|
||||
|
||||
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
|
||||
export function parseVersionIdParam(raw: string):
|
||||
| { ok: true; versionId: string }
|
||||
| DocverError {
|
||||
const versionId = raw.trim().toLowerCase();
|
||||
if (!UUID_RE.test(versionId)) {
|
||||
return { ok: false, error: "versionId ต้องเป็น UUID" };
|
||||
}
|
||||
return { ok: true, versionId };
|
||||
}
|
||||
|
||||
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
|
||||
export function parseDownloadFormat(raw: string | null):
|
||||
| { ok: true; format: "docx" | "pdf" }
|
||||
| DocverError {
|
||||
const format = raw?.trim() ?? "";
|
||||
if (format === "") return { ok: true, format: "docx" };
|
||||
if (format === "docx" || format === "pdf") return { ok: true, format };
|
||||
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
||||
}
|
||||
|
||||
function trimOrNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
|
||||
function formString(form: FormData, field: string):
|
||||
| { ok: true; value: string | null }
|
||||
| DocverError {
|
||||
const entry = form.get(field);
|
||||
if (entry === null) return { ok: true, value: null };
|
||||
if (typeof entry !== "string") {
|
||||
return { ok: false, error: `field '${field}' ต้องเป็น string` };
|
||||
}
|
||||
return { ok: true, value: trimOrNull(entry) };
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
|
||||
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
|
||||
export function sanitizeDisplayFileName(raw: string | null): string | null {
|
||||
if (raw === null) return null;
|
||||
const base = raw.split(/[\\/]/).pop() ?? raw;
|
||||
return trimOrNull(base)?.slice(0, 200) ?? null;
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
|
||||
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
|
||||
export function sanitizeFilePartName(raw: string): string | null {
|
||||
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||
}
|
||||
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||
export function parseFieldsJson(raw: string):
|
||||
| { ok: true; fields: Record<string, string> | null }
|
||||
| DocverError {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
// เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
|
||||
// เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
|
||||
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
|
||||
if (keys.length > 1_000) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
|
||||
export type ParsedVersionUploadForm = {
|
||||
clientVersionId: string | null;
|
||||
message: string | null;
|
||||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||
fileName: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
|
||||
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
|
||||
export function parseVersionUploadForm(form: FormData):
|
||||
| { ok: true; form: ParsedVersionUploadForm }
|
||||
| DocverError {
|
||||
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
|
||||
// downlevelIteration — array ธรรมดาไม่ต้อง)
|
||||
for (const key of Array.from(form.keys())) {
|
||||
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const clientVersionId = formString(form, "clientVersionId");
|
||||
if (!clientVersionId.ok) return clientVersionId;
|
||||
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
||||
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const message = formString(form, "message");
|
||||
if (!message.ok) return message;
|
||||
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
||||
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||
}
|
||||
|
||||
const rawFields = formString(form, "fields");
|
||||
if (!rawFields.ok) return rawFields;
|
||||
let fields: Record<string, string> | null = null;
|
||||
if (rawFields.value !== null) {
|
||||
const parsed = parseFieldsJson(rawFields.value);
|
||||
if (!parsed.ok) return parsed;
|
||||
fields = parsed.fields;
|
||||
}
|
||||
|
||||
const rawFileName = formString(form, "fileName");
|
||||
if (!rawFileName.ok) return rawFileName;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
form: {
|
||||
clientVersionId: clientVersionId.value,
|
||||
message: message.value,
|
||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||
fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
|
||||
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
|
||||
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
|
||||
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
|
||||
|
||||
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
|
||||
const ext = `.${kind}`;
|
||||
const ascii = (displayName ?? "")
|
||||
.replace(/[^\x20-\x7E]/g, "")
|
||||
.replace(/["\\]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
let asciiName: string = DEFAULT_FILE_NAMES[kind];
|
||||
if (/[A-Za-z0-9]/.test(ascii)) {
|
||||
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
|
||||
}
|
||||
const display = displayName?.trim();
|
||||
if (display) {
|
||||
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
|
||||
}
|
||||
return `attachment; filename="${asciiName}"`;
|
||||
}
|
||||
275
lib/docver-store.ts
Normal file
275
lib/docver-store.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// PLAN-DOCVER.md §2/§4/§6 — MinIO + DB ของกลุ่ม /api/documents (server-only)
|
||||
// - env อ่านตอนเรียก (ไม่ใช่ตอน module load) — ขาด/ผิด = throw ให้ route catch เป็น 500
|
||||
// - object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้
|
||||
// (เหตุผลเดียวกับ N4 ที่ห้ามชื่อผู้ใช้ตกแต่ง path) — versionId สร้างฝั่ง server แล้ว
|
||||
// ส่งเป็น id ตอน insert เพราะ object เขียนลง MinIO ไปก่อนแล้ว
|
||||
// - เขียน DB ผ่าน route handler (carve-out ของ invariant #4 — ตารางของกลุ่มนี้ไม่มีหน้า
|
||||
// note ใดอ่าน จึงไม่มี SSE/revalidatePath ที่ต้องทำ)
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { Client } from "minio";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
documents,
|
||||
documentVersions,
|
||||
type DocumentRow,
|
||||
type DocumentVersionRow,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
contentDispositionValue,
|
||||
DOCX_CONTENT_TYPE,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "@/lib/document-versioning";
|
||||
|
||||
type MinioConfig = {
|
||||
endPoint: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
/** อ่าน env ทุกครั้งที่มีการเรียกใช้ — ค่าบังคับขาด = throw (route แปลงเป็น 500 กลาง ๆ
|
||||
* ไม่บอกผู้เรียกว่า env ตัวไหนขาด — log เต็มอยู่ฝั่ง server แล้ว) */
|
||||
function readConfig(): MinioConfig {
|
||||
const endPoint = process.env.MINIO_ENDPOINT?.trim();
|
||||
const accessKey = process.env.MINIO_ACCESS_KEY?.trim();
|
||||
const secretKey = process.env.MINIO_SECRET_KEY?.trim();
|
||||
if (!endPoint || !accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"ตั้งค่า MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY ก่อนใช้ API กลุ่มเอกสาร versioning (docs/PLAN-DOCVER.md §6)",
|
||||
);
|
||||
}
|
||||
const port = Number.parseInt(process.env.MINIO_PORT?.trim() ?? "", 10);
|
||||
const ttl = Number.parseInt(process.env.DOCVER_LINK_TTL_SECONDS?.trim() ?? "", 10);
|
||||
return {
|
||||
endPoint,
|
||||
port: Number.isFinite(port) && port > 0 ? port : 9000,
|
||||
useSSL: /^(1|true|yes)$/i.test(process.env.MINIO_USE_SSL?.trim() ?? ""),
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket: process.env.MINIO_BUCKET?.trim() || "docver",
|
||||
// ไม่ให้ตั้งเกิน 7 วัน — ขอบบนของ S3 presign เอง
|
||||
ttlSeconds:
|
||||
Number.isFinite(ttl) && ttl > 0 ? Math.min(ttl, 7 * 24 * 60 * 60) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
// เก็บ client/bucket บน globalThis แบบเดียวกับ db/index.ts — Next dev hot-reload
|
||||
// re-evaluate module บ่อย ไม่งั้นสะสม client จนเปลือง; key คิดจาก config ที่อ่านได้
|
||||
// ตอนนั้นเพื่อ env เปลี่ยนกลาง dev แล้ว client เก่าไม่ติดค้าง
|
||||
const globalForMinio = globalThis as unknown as {
|
||||
docverClient?: { key: string; client: Client };
|
||||
docverBucketReady?: { bucket: string; promise: Promise<void> };
|
||||
};
|
||||
|
||||
function getClient(): { client: Client; config: MinioConfig } {
|
||||
const config = readConfig();
|
||||
const key = `${config.endPoint}|${config.port}|${config.useSSL}|${config.accessKey}`;
|
||||
if (globalForMinio.docverClient?.key !== key) {
|
||||
globalForMinio.docverClient = {
|
||||
key,
|
||||
client: new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { client: globalForMinio.docverClient.client, config };
|
||||
}
|
||||
|
||||
/** สร้าง bucket ถ้ายังไม่มี — ครั้งเดียวต่อ process (cached promise กัน request
|
||||
* พร้อมกันยิง makeBucket ซ้ำ) · ชน race ข้าม process (อีก process สร้างแย่งกัน)
|
||||
* = ตรวจ bucketExists ซ้ำอีกรอบ มีแล้ว = ผ่าน · ล้มจริง = เคลียร์ cache ให้รอบหลังลองใหม่ */
|
||||
async function ensureBucket(client: Client, bucket: string): Promise<void> {
|
||||
if (globalForMinio.docverBucketReady?.bucket !== bucket) {
|
||||
const promise = (async () => {
|
||||
if (await client.bucketExists(bucket)) return;
|
||||
try {
|
||||
await client.makeBucket(bucket);
|
||||
} catch (err) {
|
||||
if (!(await client.bucketExists(bucket))) throw err;
|
||||
}
|
||||
})();
|
||||
globalForMinio.docverBucketReady = { bucket, promise };
|
||||
promise.catch(() => {
|
||||
// อย่าปล่อย rejected promise ค้างใน cache — request ถัดไปต้องได้ลองใหม่
|
||||
if (globalForMinio.docverBucketReady?.promise === promise) {
|
||||
globalForMinio.docverBucketReady = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return globalForMinio.docverBucketReady.promise;
|
||||
}
|
||||
|
||||
/** object key ของ version หนึ่ง — uuid ล้วน ใช้ร่วมทั้งตอน put และตอน insert row */
|
||||
export function versionObjectKeys(versionId: string): { docx: string; pdf: string } {
|
||||
return {
|
||||
docx: `docver/${versionId}/document.docx`,
|
||||
pdf: `docver/${versionId}/document.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
export type PutVersionInput = {
|
||||
versionId: string;
|
||||
docx: { bytes: Buffer; fileName: string | null };
|
||||
pdf?: { bytes: Buffer; fileName: string | null };
|
||||
};
|
||||
|
||||
/** เขียน object ของ version ลง MinIO (docx ก่อน pdf) — ฝัง Content-Type +
|
||||
* Content-Disposition ตอน put เพื่อให้ presigned GET ได้ชื่อไฟล์สวยตอนดาวน์โหลด
|
||||
* · pdf ล้มกลางทาง = เก็บกวาด docx ที่เขียนไปแล้ว (ไม่ให้เกิด version ครึ่ง ๆ) */
|
||||
export async function putVersionObjects(input: PutVersionInput): Promise<void> {
|
||||
const { client, config } = getClient();
|
||||
await ensureBucket(client, config.bucket);
|
||||
const keys = versionObjectKeys(input.versionId);
|
||||
await client.putObject(config.bucket, keys.docx, input.docx.bytes, input.docx.bytes.length, {
|
||||
"Content-Type": DOCX_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.docx.fileName, "docx"),
|
||||
});
|
||||
if (!input.pdf) return;
|
||||
try {
|
||||
await client.putObject(config.bucket, keys.pdf, input.pdf.bytes, input.pdf.bytes.length, {
|
||||
"Content-Type": PDF_CONTENT_TYPE,
|
||||
"Content-Disposition": contentDispositionValue(input.pdf.fileName, "pdf"),
|
||||
});
|
||||
} catch (err) {
|
||||
await client.removeObject(config.bucket, keys.docx).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** เก็บกวาด object หลัง DB ล้ม (pattern unlink + rethrow ของ N4) — best-effort ไม่โยน:
|
||||
* เหลือไว้ก็เป็น object ไร้ row ชี้ (key เป็น uuid ไม่ชนใคร — PLAN-DOCVER.md §4) */
|
||||
export async function removeVersionObjects(
|
||||
versionId: string,
|
||||
hadPdf: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { client, config } = getClient();
|
||||
const keys = versionObjectKeys(versionId);
|
||||
await client.removeObject(config.bucket, keys.docx);
|
||||
if (hadPdf) await client.removeObject(config.bucket, keys.pdf);
|
||||
} catch (err) {
|
||||
console.error("docver: เก็บกวาด object หลังงานล้มไม่สำเร็จ (ไม่มีผลต่อ response)", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** presigned GET URL อายุสั้นให้ UI เปิดในเบราว์เซอร์จริงของผู้ใช้ (webview ของ Word
|
||||
* ทิ้ง blob download เงียบ ๆ — เหตุผลเดียวกับแผน add-in เดิม) · presign เป็นการ
|
||||
* คำนวณ local ล้วนไม่มี network call */
|
||||
export async function presignVersionUrl(
|
||||
versionId: string,
|
||||
format: "docx" | "pdf",
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
const { client, config } = getClient();
|
||||
const key = versionObjectKeys(versionId)[format];
|
||||
const url = await client.presignedGetObject(config.bucket, key, config.ttlSeconds);
|
||||
return {
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + config.ttlSeconds * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
|
||||
const [row] = await db.select().from(documents).where(eq(documents.docId, docId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** history ของเอกสาร — ใหม่ → เก่า ตาม contract ของ add-in */
|
||||
export async function listVersionsByDocId(documentId: string): Promise<DocumentVersionRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, documentId))
|
||||
.orderBy(desc(documentVersions.versionNumber));
|
||||
}
|
||||
|
||||
/** หา version เดียวโดยเช็คทั้ง docId และ versionId พร้อมกัน — กันโยน id ข้ามเอกสาร */
|
||||
export async function findVersionForDoc(
|
||||
docId: string,
|
||||
versionId: string,
|
||||
): Promise<DocumentVersionRow | null> {
|
||||
const [row] = await db
|
||||
.select({ version: documentVersions })
|
||||
.from(documentVersions)
|
||||
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
|
||||
.where(and(eq(documents.docId, docId), eq(documentVersions.id, versionId)))
|
||||
.limit(1);
|
||||
return row?.version ?? null;
|
||||
}
|
||||
|
||||
export type InsertVersionInput = {
|
||||
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
|
||||
versionId: string;
|
||||
docId: string;
|
||||
clientVersionId: string | null;
|
||||
contentHash: string;
|
||||
message: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
templateId: string | null;
|
||||
fileName: string | null;
|
||||
docxObjectKey: string;
|
||||
pdfObjectKey: string | null;
|
||||
docxSize: number;
|
||||
pdfSize: number | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
/** สร้าง (ถ้ายังไม่มี) เอกสาร + insert version พร้อมจ่ายเลข version ปลอดภัยเรียกคู่ขนาน
|
||||
* (PLAN-DOCVER.md §3): ON CONFLICT DO NOTHING สร้าง row เอกสาร → SELECT … FOR UPDATE
|
||||
* serialize ผู้จ่ายเลขรายเอกสาร (unique doc_id ได้ row เดียวเสมอ ไม่มี deadlock) →
|
||||
* max+1 → insert (UNIQUE(document_id, version_number) เป็น safety net) */
|
||||
export async function insertVersionRecord(input: InsertVersionInput): Promise<DocumentVersionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(documents)
|
||||
.values({ docId: input.docId })
|
||||
.onConflictDoNothing({ target: documents.docId });
|
||||
|
||||
const [doc] = await tx
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.docId, input.docId))
|
||||
.for("update");
|
||||
if (!doc) {
|
||||
// ไปไม่ถึงเว้นแต่ unique index หาย — เผื่อไว้ให้ error อ่านง่ายกว่า undefined
|
||||
throw new Error("docver: ไม่พบ row เอกสารหลัง upsert (unique index documents.doc_id)");
|
||||
}
|
||||
|
||||
const [latest] = await tx
|
||||
.select({ max: sql<number | null>`max(${documentVersions.versionNumber})` })
|
||||
.from(documentVersions)
|
||||
.where(eq(documentVersions.documentId, doc.id));
|
||||
|
||||
const [row] = await tx
|
||||
.insert(documentVersions)
|
||||
.values({
|
||||
id: input.versionId,
|
||||
documentId: doc.id,
|
||||
versionNumber: (latest?.max ?? 0) + 1,
|
||||
clientVersionId: input.clientVersionId,
|
||||
contentHash: input.contentHash,
|
||||
message: input.message,
|
||||
fields: input.fields,
|
||||
templateId: input.templateId,
|
||||
fileName: input.fileName,
|
||||
docxObjectKey: input.docxObjectKey,
|
||||
pdfObjectKey: input.pdfObjectKey,
|
||||
docxSize: input.docxSize,
|
||||
pdfSize: input.pdfSize,
|
||||
userId: input.userId,
|
||||
userName: input.userName,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
223
lib/docver-token.ts
Normal file
223
lib/docver-token.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
||||
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
||||
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
||||
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
||||
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
||||
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
||||
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
||||
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
||||
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
|
||||
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
|
||||
//
|
||||
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { docverError, type DocverError } from "@/lib/document-versioning";
|
||||
|
||||
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
||||
export type DocverTokenClaims = {
|
||||
userid: string;
|
||||
username: string;
|
||||
fid: string;
|
||||
template: string | null;
|
||||
};
|
||||
|
||||
function base64url(input: string): string {
|
||||
return Buffer.from(input, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = Buffer.from(segment, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
|
||||
export function issueDocverToken(
|
||||
claims: DocverTokenClaims,
|
||||
secret: string,
|
||||
ttlSeconds: number,
|
||||
): string {
|
||||
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const payload = base64url(
|
||||
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
|
||||
);
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
||||
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
||||
function claimsFromPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): DocverTokenClaims | null {
|
||||
const { userid, username, fid, template } = payload;
|
||||
if (typeof userid !== "string" || userid === "") return null;
|
||||
if (typeof username !== "string" || username === "") return null;
|
||||
if (typeof fid !== "string" || fid === "") return null;
|
||||
if (template !== null && typeof template !== "string") return null;
|
||||
return { userid, username, fid, template };
|
||||
}
|
||||
|
||||
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
|
||||
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
|
||||
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
|
||||
export function verifyDocverToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
|
||||
const segments = token.split(".");
|
||||
if (segments.length !== 3) return { ok: false };
|
||||
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
||||
|
||||
const header = decodeJsonSegment(headerSegment);
|
||||
if (header === null || header.alg !== "HS256") return { ok: false };
|
||||
|
||||
const expected = createHmac("sha256", secret)
|
||||
.update(`${headerSegment}.${payloadSegment}`)
|
||||
.digest();
|
||||
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
||||
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
||||
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
||||
const provided = createHash("sha256")
|
||||
.update(Buffer.from(signatureSegment, "base64url"))
|
||||
.digest();
|
||||
const expectedHash = createHash("sha256").update(expected).digest();
|
||||
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
|
||||
|
||||
const payload = decodeJsonSegment(payloadSegment);
|
||||
if (payload === null) return { ok: false };
|
||||
const exp = payload.exp;
|
||||
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
||||
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
||||
|
||||
const claims = claimsFromPayload(payload);
|
||||
if (claims === null) return { ok: false };
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
|
||||
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
|
||||
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||
export function requireDocverApiKey(request: Request): Response | null {
|
||||
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||
const envKey = process.env.DOCVER_API_KEY?.trim();
|
||||
// const envKey =
|
||||
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
||||
if (envKey === undefined || envKey === "") {
|
||||
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
||||
}
|
||||
const provided = request.headers.get("x-api-key");
|
||||
if (provided === null || provided === "") {
|
||||
return docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
const providedHash = createHash("sha256").update(provided).digest();
|
||||
const envHash = createHash("sha256").update(envKey).digest();
|
||||
return timingSafeEqual(providedHash, envHash)
|
||||
? null
|
||||
: docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
|
||||
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
||||
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
||||
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
||||
|
||||
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
||||
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
||||
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
||||
export function requireDocverBearer(
|
||||
request: Request,
|
||||
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
||||
const denied = () =>
|
||||
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
// const secret =
|
||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||
if (secret === undefined || secret === "") return denied();
|
||||
const header = request.headers.get("authorization");
|
||||
if (header === null) return denied();
|
||||
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
if (match === null) return denied();
|
||||
const verified = verifyDocverToken(match[1], secret);
|
||||
if (!verified.ok) return denied();
|
||||
return { ok: true, claims: verified.claims };
|
||||
}
|
||||
|
||||
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
||||
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
||||
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
||||
|
||||
export function parseTokenRequestBody(
|
||||
raw: unknown,
|
||||
): { ok: true; claims: DocverTokenClaims } | DocverError {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ok: false, error: "body ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawUserid = record.userid;
|
||||
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ userid (string)" };
|
||||
}
|
||||
const userid = rawUserid.trim();
|
||||
if (userid.length > 100) {
|
||||
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawUsername = record.username;
|
||||
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ username (string)" };
|
||||
}
|
||||
const username = rawUsername.trim();
|
||||
if (username.length > 200) {
|
||||
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawFid = record.fid;
|
||||
if (typeof rawFid !== "string" || rawFid.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ fid (string)" };
|
||||
}
|
||||
const fid = rawFid.trim();
|
||||
if (fid.length > 200) {
|
||||
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
|
||||
}
|
||||
|
||||
const rawTemplate = record.template ?? null;
|
||||
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
||||
return { ok: false, error: "template ต้องเป็น string" };
|
||||
}
|
||||
const template = rawTemplate?.trim() || null;
|
||||
if (template !== null && template.length > 100) {
|
||||
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
||||
}
|
||||
|
||||
return { ok: true, claims: { userid, username, fid, template } };
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
|
||||
// N10 (PLAN-API.md D1) — auth ของ /api/note/external/* (ผู้เรียก: backend e-Court,
|
||||
// server-to-server ไม่มี browser)
|
||||
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
|
||||
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/external/documents
|
||||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
|
||||
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
|
||||
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
|
||||
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
|
||||
|
|
@ -81,7 +81,7 @@ function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
|
|||
}
|
||||
|
||||
/**
|
||||
* validate JSON body ของ POST /api/external/documents (PLAN-API.md D2) — strict กัน
|
||||
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
|
||||
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
|
||||
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
|
||||
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
|
||||
|
|
|
|||
1314
lib/lib-tmpl.ts
Executable file
1314
lib/lib-tmpl.ts
Executable file
File diff suppressed because it is too large
Load diff
112
lib/lib-utils.ts
Normal file
112
lib/lib-utils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
|
||||
import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from 'node:crypto';
|
||||
/**
|
||||
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
|
||||
*/
|
||||
export function generateHmacSha256(data: string | object, secretKey: string): string {
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
return createHmac('sha256', secretKey)
|
||||
.update(payload, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
|
||||
*/
|
||||
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
|
||||
const calculatedHmac = generateHmacSha256(data, secretKey);
|
||||
const a = Buffer.from(calculatedHmac, 'hex');
|
||||
const b = Buffer.from(expectedHmac, 'hex');
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
// ==========================================
|
||||
// ตัวอย่างการใช้งาน
|
||||
// ==========================================
|
||||
/*
|
||||
const secret = 'my-super-secret-key';
|
||||
const jsonData = {
|
||||
userId: 12345,
|
||||
action: 'transfer',
|
||||
amount: 500
|
||||
};
|
||||
|
||||
// 1. สร้าง HMAC
|
||||
const hmacSignature = generateHmacSha256(jsonData, secret);
|
||||
console.log('HMAC Signature (Hex):', hmacSignature);
|
||||
|
||||
// 2. ตรวจสอบ HMAC
|
||||
const isValid = verifyHmacSha256(jsonData, secret, hmacSignature);
|
||||
console.log('Is Signature Valid?:', isValid); // true
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign) ข้อมูลด้วย Ed25519 Private Key (Hex)
|
||||
* @param message ข้อความดิบที่ต้องการเซ็น (string หรือ Buffer)
|
||||
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
|
||||
*/
|
||||
export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
|
||||
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
|
||||
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
|
||||
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
|
||||
// PKCS#8 Header สำหรับ Ed25519 Private Key
|
||||
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||
const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]);
|
||||
|
||||
const privateKey = createPrivateKey({
|
||||
key: pkcs8Buffer,
|
||||
format: 'der',
|
||||
type: 'pkcs8',
|
||||
});
|
||||
|
||||
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||
const signatureBuffer = sign(null, dataBuffer, privateKey);
|
||||
return signatureBuffer.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
|
||||
* @param message ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
|
||||
* @param signatureHex ลายเซ็นในรูปแบบ Hex String
|
||||
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
||||
*/
|
||||
export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
|
||||
try {
|
||||
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
|
||||
|
||||
// SPKI Header สำหรับ Ed25519 Public Key (32 bytes)
|
||||
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
|
||||
const spkiBuffer = Buffer.concat([spkiHeader, rawPublicKey]);
|
||||
|
||||
const publicKey = createPublicKey({
|
||||
key: spkiBuffer,
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
|
||||
const dataBuffer = typeof message === 'string' ? Buffer.from(message, 'utf-8') : message;
|
||||
const signatureBuffer = Buffer.from(signatureHex, 'hex');
|
||||
|
||||
return verify(null, dataBuffer, publicKey, signatureBuffer);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับเรียงคีย์ของ Object ตามตัวอักษรเพื่อไม่ให้โครงสร้าง JSON เพี้ยน
|
||||
*/
|
||||
export function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.reduce((result: Record<string, unknown>, key: string) => {
|
||||
result[key] = obj[key];
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
1181
lib/openapi.ts
Normal file
1181
lib/openapi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
|
||||
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
|
||||
// ใช้ 2 จุด: /api/note/pdf (viewer) และ /api/note/annotations/export — กัน CORS ฝั่ง browser
|
||||
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
|
||||
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import path from "node:path";
|
|||
export class PdfPathError extends Error {}
|
||||
|
||||
export function resolvePublicPdfPath(pdfUrl: string): string {
|
||||
const publicDir = path.resolve(process.cwd(), "public");
|
||||
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
|
||||
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
|
||||
const publicDir = path.resolve(process.cwd(), "public", "note");
|
||||
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
|
||||
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
|
||||
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ export function middleware(req: NextRequest) {
|
|||
if (!user) {
|
||||
const url = req.nextUrl.clone();
|
||||
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
|
||||
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /login มี query เกินจำเป็น
|
||||
// (path ตรงนี้ตัด basePath แล้ว / NextURL ใส่ basePath คืนตอน redirect เอง — invariant #12)
|
||||
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /note/login มี query เกินจำเป็น
|
||||
// (path ตรงนี้ตัด basePath แล้ว จึงขึ้นต้น /note/… เสมอ / NextURL ใส่ basePath คืนตอน
|
||||
// redirect เอง — invariant #12)
|
||||
const next = url.pathname + url.search;
|
||||
url.pathname = "/login";
|
||||
url.pathname = "/note/login";
|
||||
url.search = "";
|
||||
if (next !== "/") url.searchParams.set("next", next);
|
||||
return NextResponse.redirect(url);
|
||||
|
|
@ -20,10 +21,22 @@ export function middleware(req: NextRequest) {
|
|||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
||||
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
|
||||
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
|
||||
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
|
||||
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
|
||||
// + public/ ราก (fonts, favicon)
|
||||
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
||||
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
||||
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
|
||||
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
|
||||
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
|
||||
"/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,38 @@
|
|||
const basePath = "/docedit/note";
|
||||
const basePath = "/docedit";
|
||||
// prefix ระดับแอปของ judes-note ใน route tree (app/note/**) + statics (public/note/**)
|
||||
// — basePath /docedit เป็นของกลางแชร์กับแอปพี่น้อง (เช่น wordaddin ในอนาคต)
|
||||
// URL ปลายทางจึงยังเป็น /docedit/note/… เหมือนเดิมทุกจุด
|
||||
const appPath = "/note";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
|
||||
output: "standalone",
|
||||
// serve ใต้ /docedit/note — <Link>/next/navigation เติมให้เอง แต่ fetch/EventSource/
|
||||
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts)
|
||||
// serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath
|
||||
// ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/login") ส่วน fetch/EventSource/
|
||||
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note)
|
||||
basePath,
|
||||
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
|
||||
env: { NEXT_PUBLIC_BASE_PATH: basePath },
|
||||
env: { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_APP_PATH: appPath },
|
||||
// ปิด redirect trailing slash อัตโนมัติของ Next (308 /docedit → /docedit/
|
||||
// และ /docedit/note/ → /docedit/note) — deploy หลัง IIS แล้ว redirect พวกนี้
|
||||
// ตีกับ rewrite rule ของ IIS จนกลายเป็น ping-pong loop; ปิดให้หน้า/ไฟล์ทุกอย่าง
|
||||
// serve ได้ทั้งแบบมี/ไม่มี slash ฝั่ง proxy เป็นคนกำหนดเอง
|
||||
skipTrailingSlashRedirect: true,
|
||||
// จับเฉพาะ root ของ host ("/" แท้ ๆ — basePath:false ทำให้ rule นี้ไม่จับ
|
||||
// /docedit) เผื่อ IIS ส่ง path "/" เข้า process: ส่งตรงไป /docedit/note เลย
|
||||
// ส่วน /docedit และ /docedit/ จับที่ app/page.tsx แทน (ส่งต่อ query เอง —
|
||||
// รักษาสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId=)
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: `${basePath}${appPath}`,
|
||||
basePath: false, // ป้องกัน basePath ซ้อน (destination เขียนเต็มแล้ว)
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
|
|
|
|||
8941
package-lock.json
generated
8941
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -8,28 +8,40 @@
|
|||
"test": "vitest run",
|
||||
"start": "next start",
|
||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||
"postinstall": "node scripts/patch-pdf-worker.mjs"
|
||||
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
|
||||
"dev:https": "next dev --experimental-https",
|
||||
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"docx-templates": "^4.15.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.42.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "^14.2.35",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pizzip": "^3.3.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"utif2": "^4.1.0"
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"utif2": "^4.1.0",
|
||||
"xpath": "^0.0.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/office-js": "^1.0.612",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"office-addin-debugging": "^7.0.1",
|
||||
"office-addin-dev-settings": "^4.0.1",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
7543
pnpm-lock.yaml
generated
Normal file
7543
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
allowBuilds:
|
||||
'@azure/msal-node-extensions': set this to true or false
|
||||
'@microsoft/m365agentstoolkit-cli': set this to true or false
|
||||
'@scarf/scarf': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
keytar: set this to true or false
|
||||
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
2
public/note/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue