412 lines
22 KiB
TypeScript
412 lines
22 KiB
TypeScript
|
|
"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;
|