feat : update
CI / Verify (push) Successful in 2m9s
CI / Build immutable images and deploy (push) Successful in 2m39s

This commit is contained in:
2026-09-25 18:34:37 +07:00 Unverified
parent e8a668967f
commit 4a398f28b4
7 changed files with 1025 additions and 51 deletions
+408 -33
View File
@@ -1,20 +1,35 @@
"use client";
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ClipboardIcon,
CopyIcon,
EraserIcon,
GripHorizontalIcon,
GripVerticalIcon,
ItalicIcon,
PlusIcon,
PaletteIcon,
Redo2Icon,
ScissorsIcon,
StrikethroughIcon,
Trash2Icon,
Undo2Icon,
UnderlineIcon,
} from "lucide-react";
import Spreadsheet, {
type ColumnIndicatorProps,
type DataEditorProps,
EntireColumnsSelection,
EntireRowsSelection,
type Point,
type RowIndicatorProps,
type Selection,
type SpreadsheetRef,
type TableProps,
} from "react-spreadsheet";
import {
useCallback,
@@ -22,6 +37,7 @@ import {
useMemo,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from "react";
import {
@@ -48,6 +64,15 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation
import { RichTextContent } from "@/components/rich-text-content";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Dialog,
DialogContent,
@@ -58,22 +83,41 @@ import {
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupTextarea,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
addExtraSheetColumn,
addExtraSheetRow,
clearExtraSheetCellFormatting,
DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
deleteExtraSheetColumn,
deleteExtraSheetRow,
extraSheetRangeToTsv,
extraSheetToMatrix,
insertExtraSheetColumn,
insertExtraSheetRow,
matrixToExtraSheet,
moveExtraSheetColumn,
moveExtraSheetRow,
nextExtraSheetColumnLabel,
parseExtraSheetTsv,
pasteExtraSheetText,
resizeExtraSheetColumn,
styleExtraSheetCells,
updateExtraSheetCells,
type ExtraSheetAlignment,
type ExtraSheet,
type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet";
import {
extraRichTextToPlainText,
normalizeExtraRichText,
type RichTextStylePatch,
} from "@/lib/rich-text";
import { cn } from "@/lib/utils";
@@ -105,22 +149,41 @@ function movedIndex(current: number, from: number, to: number): number {
return current;
}
function spreadsheetColumnName(index: number): string {
let value = index + 1;
let label = "";
while (value > 0) {
value -= 1;
label = String.fromCharCode(65 + (value % 26)) + label;
value = Math.floor(value / 26);
}
return label;
}
function LiteralDataViewer({
cell,
}: {
cell?: { value?: unknown; backgroundColor?: string };
cell?: {
value?: unknown;
backgroundColor?: string;
alignment?: ExtraSheetAlignment;
};
}) {
return (
<div
className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks size-full"
style={
cell?.backgroundColor
? { backgroundColor: cell.backgroundColor }
: undefined
? {
backgroundColor: cell.backgroundColor,
textAlign: cell.alignment ?? "left",
}
: { textAlign: cell?.alignment ?? "left" }
}
>
<RichTextContent
html={normalizeExtraRichText(String(cell?.value ?? ""))}
className="extra-sheet-text"
/>
</div>
);
@@ -190,8 +253,16 @@ function EditableColumnIndicator({
column,
label,
selected,
onSelect,
onRename,
}: ColumnIndicatorProps & { id: string; onRename: (column: number) => void }) {
onResize,
width,
}: ColumnIndicatorProps & {
id: string;
onRename: (column: number) => void;
onResize: (column: number, width: number, commit: boolean) => void;
width: number;
}) {
const {
attributes,
listeners,
@@ -206,6 +277,7 @@ function EditableColumnIndicator({
return (
<th
ref={setNodeRef}
data-sheet-column={column}
className={cn(
"Spreadsheet__header",
selected && "Spreadsheet__header--selected",
@@ -229,10 +301,39 @@ function EditableColumnIndicator({
variant="ghost"
size="sm"
className="min-w-0 flex-1"
onClick={() => onRename(column)}
onClick={(event) => onSelect(column, event.shiftKey)}
onDoubleClick={() => onRename(column)}
>
{label}
</Button>
<span
role="separator"
aria-label={`ปรับความกว้าง ${String(label ?? `คอลัมน์ ${column + 1}`)}`}
aria-orientation="vertical"
tabIndex={0}
className="extra-sheet-column-resizer"
onKeyDown={(event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
onResize(column, width + (event.key === "ArrowRight" ? 8 : -8), true);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = event.currentTarget.parentElement?.getBoundingClientRect().width
?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH;
const move = (moveEvent: PointerEvent) =>
onResize(column, startWidth + moveEvent.clientX - startX, false);
const up = (upEvent: PointerEvent) => {
onResize(column, startWidth + upEvent.clientX - startX, true);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up, { once: true });
}}
/>
</th>
);
}
@@ -258,6 +359,7 @@ function SortableRowIndicator({
return (
<th
ref={setNodeRef}
data-sheet-row={row}
className={cn(
"Spreadsheet__header",
selected && "Spreadsheet__header--selected",
@@ -314,15 +416,24 @@ export function ExtraSheetEditor({
const [columnLabel, setColumnLabel] = useState("");
const [removeOpen, setRemoveOpen] = useState(false);
const [selectedCount, setSelectedCount] = useState(1);
const [selectionKind, setSelectionKind] = useState<"cells" | "rows" | "columns">("cells");
const [draftWidths, setDraftWidths] = useState<Record<number, number>>({});
const [history, setHistory] = useState<SheetHistory>({
items: [value],
index: 0,
value,
});
const selectedPointsRef = useRef<Point[]>([active]);
const selectedRangeRef = useRef({ start: active, end: active });
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
const activeColumn = value.columns[active.column];
const activeRow = value.rows[active.row];
const activeValue = activeColumn && activeRow
? activeRow.cells[activeColumn.id] ?? ""
: "";
const activeAlignment = activeColumn && activeRow
? activeRow.alignments?.[activeColumn.id] ?? "left"
: "left";
const canUndo = history.index > 0;
const canRedo = history.index < history.items.length - 1;
const sortableIds = useMemo(
@@ -380,7 +491,9 @@ export function ExtraSheetEditor({
const activateOnly = useCallback((point: Point) => {
setActive(point);
selectedPointsRef.current = [point];
selectedRangeRef.current = { start: point, end: point };
setSelectedCount(1);
setSelectionKind("cells");
window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point));
}, []);
@@ -446,8 +559,61 @@ export function ExtraSheetEditor({
} else if (redo && canRedo) {
event.preventDefault();
travel(1);
} else if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { value: "" }));
}
};
const applyStyle = (patch: RichTextStylePatch) =>
commitSheet(styleExtraSheetCells(value, selectedPointsRef.current, patch));
const clearContents = () =>
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { value: "" }));
const clearFormatting = () =>
commitSheet(clearExtraSheetCellFormatting(value, selectedPointsRef.current));
const selectedTsv = () => extraSheetRangeToTsv(
value,
selectedRangeRef.current.start,
selectedRangeRef.current.end,
);
const copySelection = async () => {
await navigator.clipboard.writeText(selectedTsv());
};
const cutSelection = async () => {
await copySelection();
clearContents();
};
const pasteText = (text: string) => {
if (!text) return;
const pasted = parseExtraSheetTsv(text);
if (selectedCount > 1 && pasted.length === 1 && pasted[0].length === 1) {
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, {
value: normalizeExtraRichText(pasted[0][0]),
}));
return;
}
commitSheet(pasteExtraSheetText(value, active, text, createId));
};
const pasteFromClipboard = async () => {
pasteText(await navigator.clipboard.readText());
};
const handleCopy = (event: ClipboardEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return;
event.preventDefault();
event.stopPropagation();
event.clipboardData.setData("text/plain", selectedTsv());
};
const handleCut = (event: ClipboardEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return;
handleCopy(event);
clearContents();
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return;
event.preventDefault();
event.stopPropagation();
pasteText(event.clipboardData.getData("text/plain"));
};
const normalizedLabel = columnLabel.trim().toLocaleLowerCase();
const labelInvalid =
!normalizedLabel ||
@@ -496,15 +662,57 @@ export function ExtraSheetEditor({
}
setLabelDialogOpen(false);
};
const insertRowAt = (index: number) => {
commitSheet(insertExtraSheetRow(value, index, createId("extra-sheet-row")));
activateOnly({ ...active, row: index });
};
const insertColumnAt = (index: number) => {
commitSheet(insertExtraSheetColumn(
value,
index,
nextExtraSheetColumnLabel(value.columns),
createId("extra-sheet-column"),
));
activateOnly({ ...active, column: index });
};
const removeActiveRow = () => {
if (value.rows.length === 1) return;
const next = deleteExtraSheetRow(value, active.row);
commitSheet(next);
activateOnly({ ...active, row: Math.min(active.row, next.rows.length - 1) });
};
const removeActiveColumn = () => {
if (value.columns.length === 1) return;
const next = deleteExtraSheetColumn(value, active.column);
commitSheet(next);
activateOnly({ ...active, column: Math.min(active.column, next.columns.length - 1) });
};
const resizeColumn = useCallback(
(column: number, width: number, commit: boolean) => {
setDraftWidths((current) => ({ ...current, [column]: width }));
if (!commit) return;
setDraftWidths((current) => {
const next = { ...current };
delete next[column];
return next;
});
commitSheet(resizeExtraSheetColumn(value, column, width));
},
[commitSheet, value],
);
const columnIndicator = useCallback(
(props: ColumnIndicatorProps) => (
<EditableColumnIndicator
{...props}
id={columnDragId(value.columns[props.column].id)}
onRename={openRenameColumn}
onResize={resizeColumn}
width={draftWidths[props.column]
?? value.columns[props.column].width
?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH}
/>
),
[openRenameColumn, value.columns],
[draftWidths, openRenameColumn, resizeColumn, value.columns],
);
const rowIndicator = useCallback(
(props: RowIndicatorProps) => (
@@ -528,14 +736,46 @@ export function ExtraSheetEditor({
),
[clearInitialEditText],
);
const sheetTable = useCallback(
({ children, columns, hideColumnIndicators }: TableProps) => (
<table className="Spreadsheet__table">
<colgroup>
{!hideColumnIndicators ? <col className="extra-sheet-row-number-column" /> : null}
{Array.from({ length: columns }, (_, index) => (
<col
key={value.columns[index]?.id ?? index}
style={{
width: draftWidths[index]
?? value.columns[index]?.width
?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
}}
/>
))}
</colgroup>
<tbody>{children}</tbody>
</table>
),
[draftWidths, value.columns],
);
const activeFormats = [
/<strong\b/i.test(activeValue) ? "bold" : null,
/<em\b/i.test(activeValue) ? "italic" : null,
/<u\b/i.test(activeValue) ? "underline" : null,
/<s\b/i.test(activeValue) ? "strike" : null,
].filter((format): format is string => Boolean(format));
const activeTextColor = activeValue.match(/color:\s*(#[0-9A-Fa-f]{6})/i)?.[1]
?? "#111111";
return (
<div
className="flex min-w-0 flex-col gap-3"
aria-label="ตารางข้อมูลเสริม"
onKeyDown={handleSheetKeyDown}
onCopyCapture={handleCopy}
onCutCapture={handleCut}
onPasteCapture={handlePaste}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<SimpleTooltip
text={
<>
@@ -554,6 +794,55 @@ export function ExtraSheetEditor({
<Undo2Icon />
</Button>
</SimpleTooltip>
<ToggleGroup
multiple
value={activeFormats}
variant="outline"
size="sm"
spacing={0}
aria-label="รูปแบบตัวอักษร"
onValueChange={(formats) => {
const patch: RichTextStylePatch = {};
(["bold", "italic", "underline", "strike"] as const).forEach((format) => {
const wasActive = activeFormats.includes(format);
const isActive = formats.includes(format);
if (wasActive !== isActive) patch[format] = isActive;
});
if (Object.keys(patch).length) applyStyle(patch);
}}
>
<ToggleGroupItem value="bold" aria-label="ตัวหนา">
<BoldIcon />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="ตัวเอียง">
<ItalicIcon />
</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="ขีดเส้นใต้">
<UnderlineIcon />
</ToggleGroupItem>
<ToggleGroupItem value="strike" aria-label="ขีดฆ่า">
<StrikethroughIcon />
</ToggleGroupItem>
</ToggleGroup>
<label className="relative shrink-0">
<Button
type="button"
variant="outline"
size="icon-sm"
render={<span />}
nativeButton={false}
aria-label="สีตัวอักษร"
>
<span className="font-semibold underline decoration-2" style={{ textDecorationColor: activeTextColor }}>A</span>
</Button>
<input
type="color"
aria-label="เลือกสีตัวอักษร"
className="absolute inset-0 cursor-pointer opacity-0"
value={activeTextColor}
onChange={(event) => applyStyle({ color: event.target.value })}
/>
</label>
<SimpleTooltip
text={
<>
@@ -603,7 +892,7 @@ export function ExtraSheetEditor({
nativeButton={false}
>
<PaletteIcon data-icon="inline-start" />
สีพื้นหลัง {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
สีพื้นหลัง
</Button>
<input
type="color"
@@ -622,20 +911,28 @@ export function ExtraSheetEditor({
}}
/>
</label>
<Button
type="button"
variant="ghost"
<ToggleGroup
value={[activeAlignment]}
variant="outline"
size="sm"
onClick={() =>
commitSheet(
updateExtraSheetCells(value, selectedPointsRef.current, {
backgroundColor: null,
}),
)
}
spacing={0}
aria-label="จัดแนวข้อความ"
onValueChange={(alignments) => {
const alignment = alignments[0] as ExtraSheetAlignment | undefined;
if (alignment) {
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { alignment }));
}
}}
>
ล้างสีพื้นหลัง
</Button>
<ToggleGroupItem value="left" aria-label="ชิดซ้าย"><AlignLeftIcon /></ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="กึ่งกลาง"><AlignCenterIcon /></ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="ชิดขวา"><AlignRightIcon /></ToggleGroupItem>
</ToggleGroup>
<SimpleTooltip text="ล้างรูปแบบ">
<Button type="button" variant="ghost" size="icon-sm" aria-label="ล้างรูปแบบ" onClick={clearFormatting}>
<EraserIcon />
</Button>
</SimpleTooltip>
<Button
type="button"
variant="ghost"
@@ -647,20 +944,49 @@ export function ExtraSheetEditor({
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<DndContext
sensors={sensors}
collisionDetection={sameAxisCollision}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<Spreadsheet
<InputGroup>
<InputGroupAddon className="min-w-16 font-mono">
{selectedCount > 1
? `${selectedCount} cells`
: `${spreadsheetColumnName(active.column)}${active.row + 1}`}
</InputGroupAddon>
<InputGroupTextarea
key={`${active.row}:${active.column}:${activeValue}`}
aria-label="ค่าของเซลล์ที่เลือก"
rows={1}
defaultValue={extraRichTextToPlainText(activeValue)}
onBlur={(event) => {
if (event.currentTarget.value !== extraRichTextToPlainText(activeValue)) {
commitSheet(updateExtraSheetCells(value, [active], {
value: normalizeExtraRichText(event.currentTarget.value),
}));
}
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
</InputGroup>
<ContextMenu>
<ContextMenuTrigger className="extra-sheet-scroll max-h-[32rem] overflow-auto rounded-lg border">
<DndContext
sensors={sensors}
collisionDetection={sameAxisCollision}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<Spreadsheet
ref={spreadsheetRef}
className="extra-sheet-grid"
data={matrix}
columnLabels={value.columns.map((column) => column.label)}
ColumnIndicator={columnIndicator}
RowIndicator={rowIndicator}
Table={sheetTable}
DataViewer={LiteralDataViewer}
DataEditor={dataEditor}
onKeyDown={(event) => {
@@ -690,12 +1016,23 @@ export function ExtraSheetEditor({
setActive(point);
setSelectedCount(1);
selectedPointsRef.current = [point];
selectedRangeRef.current = { start: point, end: point };
setSelectionKind("cells");
}}
onSelect={(nextSelection) => {
onSelect={(nextSelection: Selection) => {
const range = nextSelection.toRange(matrix);
if (range) {
setActive(range.start);
selectedPointsRef.current = Array.from(range);
selectedRangeRef.current = { start: range.start, end: range.end };
setSelectedCount(range.size());
setSelectionKind(
nextSelection instanceof EntireRowsSelection
? "rows"
: nextSelection instanceof EntireColumnsSelection
? "columns"
: "cells",
);
}
}}
onChange={(next) =>
@@ -703,10 +1040,48 @@ export function ExtraSheetEditor({
matrixToExtraSheet(value, next as ExtraSheetMatrix, createId),
)
}
/>
</SortableContext>
</DndContext>
</div>
/>
</SortableContext>
</DndContext>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuItem onClick={() => void cutSelection()}>
<ScissorsIcon /> ตัด <ContextMenuShortcut>Ctrl/⌘ X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => void copySelection()}>
<CopyIcon /> คัดลอก <ContextMenuShortcut>Ctrl/⌘ C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => void pasteFromClipboard()}>
<ClipboardIcon /> วาง <ContextMenuShortcut>Ctrl/⌘ V</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
{selectionKind === "rows" ? (
<ContextMenuGroup>
<ContextMenuItem onClick={() => insertRowAt(active.row)}>เพิ่มแถวด้านบน</ContextMenuItem>
<ContextMenuItem onClick={() => insertRowAt(active.row + 1)}>เพิ่มแถวด้านล่าง</ContextMenuItem>
<ContextMenuItem variant="destructive" disabled={value.rows.length === 1} onClick={removeActiveRow}>
<Trash2Icon /> ลบแถว
</ContextMenuItem>
</ContextMenuGroup>
) : selectionKind === "columns" ? (
<ContextMenuGroup>
<ContextMenuItem onClick={() => insertColumnAt(active.column)}>เพิ่มคอลัมน์ด้านซ้าย</ContextMenuItem>
<ContextMenuItem onClick={() => insertColumnAt(active.column + 1)}>เพิ่มคอลัมน์ด้านขวา</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameColumn()}>เปลี่ยนชื่อคอลัมน์</ContextMenuItem>
<ContextMenuItem variant="destructive" disabled={value.columns.length === 1} onClick={removeActiveColumn}>
<Trash2Icon /> ลบคอลัมน์
</ContextMenuItem>
</ContextMenuGroup>
) : (
<ContextMenuGroup>
<ContextMenuItem onClick={clearContents}>ล้างเนื้อหา</ContextMenuItem>
<ContextMenuItem onClick={clearFormatting}><EraserIcon /> ล้างรูปแบบ</ContextMenuItem>
</ContextMenuGroup>
)}
</ContextMenuContent>
</ContextMenu>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent>