feat(wordaddin): add template tools tab
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
แท็บที่สองของ taskpane "เครื่องมือ Template" (TemplateToolsPanel + templateService — Office.js ล้วน ไม่ยิง backend ใช้ได้ทั้ง phase untracked/tracking) สำหรับคนออกแบบ template: - สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + ปุ่มดูช่อง (select เลื่อน Word ไปให้เอง) - กรอกค่าทดสอบเขียนกลับเอกสารจริงเพื่อดูผลจัดหน้า — ช่องว่างไม่ถูกแตะ, เขียนแยก Word.run ราย tag ให้ fail เฉพาะตัวพร้อมสาเหตุจาก debugInfo - แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor — page break มี fallback OOXML block-level กัน E_FAIL ในบางบริบท (เช่น cursor ในตาราง) - มองเห็น CC ทุกชนิดผ่าน feature-detect WordApi 1.5 (Document.getContentControls) — Office 2019 เห็นเฉพาะ rich text พร้อม warning แยก state ไม่โดน banner อื่นล้าง, ห้ามยก MinVersion ของ manifest จาก code review 2026-10-20: - ปุ่ม toggle แท็บปิดระหว่าง flow ที่เขียนเอกสารครบทุกตัว (saving/starting/saveBusy/templateBusy — panel รายงาน busy ผ่าน onBusyChange พร้อม cleanup ตอน unmount) กัน flow versioning มา stamp/capture ซ้อน operation ของแท็บ template ที่ยังรันอยู่ - banner auto-save error ยกจาก SaveControls มาไว้เหนือ toggle ให้เห็นทั้งสองแท็บ - สแกน merge หลังเขียนค่าตัด tag ที่หายไปจากเอกสารทิ้ง (ไม่ค้าง ไปโดน missingTags รอบถัดไป) - รวม describeWordError เป็น getDetailedErrorMessage ที่ errors.ts Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
a172b111a3
commit
6daee15851
7 changed files with 823 additions and 47 deletions
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue