"use client"; import { GripHorizontalIcon, GripVerticalIcon, PlusIcon, PencilIcon, PaletteIcon, Redo2Icon, Trash2Icon, Undo2Icon, } from "lucide-react"; import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point, type RowIndicatorProps, type SpreadsheetRef, } from "react-spreadsheet"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, } from "react"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type CollisionDetection, type DragEndEvent, } from "@dnd-kit/core"; import { horizontalListSortingStrategy, rectSortingStrategy, sortableKeyboardCoordinates, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog"; import { RichTextContent } from "@/components/rich-text-content"; import { RichTextEditor } from "@/components/rich-text-editor"; import { SimpleTooltip } from "@/components/tooltip"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Kbd } from "@/components/ui/kbd"; import { addExtraSheetColumn, addExtraSheetRow, extraSheetToMatrix, matrixToExtraSheet, moveExtraSheetColumn, moveExtraSheetRow, nextExtraSheetColumnLabel, styleExtraSheetCells, updateExtraSheetCell, updateExtraSheetCells, type ExtraSheet, type ExtraSheetMatrix, } from "@/lib/guides/extra-sheet"; import { normalizeExtraRichText, richTextContentSignature, type RichTextStylePatch, } from "@/lib/rich-text"; import { cn } from "@/lib/utils"; interface SheetHistory { items: ExtraSheet[]; index: number; value: ExtraSheet; } const DEFAULT_CELL_BACKGROUND = "#16A34A"; const SHEET_HISTORY_LIMIT = 80; const columnDragId = (id: string) => `column:${id}`; const rowDragId = (id: string) => `row:${id}`; const sameAxisCollision: CollisionDetection = (args) => { const axis = String(args.active.id).split(":", 1)[0]; return closestCenter({ ...args, droppableContainers: args.droppableContainers.filter((container) => String(container.id).startsWith(`${axis}:`), ), }); }; function movedIndex(current: number, from: number, to: number): number { if (current === from) return to; if (from < to && current > from && current <= to) return current - 1; if (to < from && current >= to && current < from) return current + 1; return current; } function LiteralDataViewer({ cell, }: { cell?: { value?: unknown; backgroundColor?: string }; }) { return (
); } function InlineDataEditor({ cell, onChange, }: DataEditorProps<{ value: string }>) { const inputRef = useRef(null); const text = useMemo(() => { const document = new DOMParser().parseFromString( normalizeExtraRichText(String(cell?.value ?? "")), "text/html", ); document.querySelectorAll("br").forEach((br) => br.replaceWith("\n")); document.querySelectorAll("p + p").forEach((p) => p.prepend("\n")); return document.body.textContent ?? ""; }, [cell?.value]); useEffect(() => { const input = inputRef.current; input?.focus(); input?.setSelectionRange(input.value.length, input.value.length); }, []); return (
onChange({ ...cell, value: event.target.value })} />
); } function EditableColumnIndicator({ id, column, label, selected, onRename, }: ColumnIndicatorProps & { id: string; onRename: (column: number) => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, strategy: horizontalListSortingStrategy, }); return ( ); } function SortableRowIndicator({ id, row, label, selected, onSelect, }: RowIndicatorProps & { id: string }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, strategy: verticalListSortingStrategy, }); return ( ); } export function ExtraSheetEditor({ value, createId, onChange, onRemove, }: { value: ExtraSheet; createId: (prefix: string) => string; onChange: (value: ExtraSheet) => void; onRemove: () => void; }) { const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const spreadsheetRef = useRef(null); const [active, setActive] = useState({ row: 0, column: 0 }); const [labelDialogOpen, setLabelDialogOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [columnLabel, setColumnLabel] = useState(""); const [removeOpen, setRemoveOpen] = useState(false); const [cellDialogOpen, setCellDialogOpen] = useState(false); const [cellDraft, setCellDraft] = useState(""); const [originalCellDraft, setOriginalCellDraft] = useState(""); const [stylePatch, setStylePatch] = useState({}); const [selectedCount, setSelectedCount] = useState(1); const [history, setHistory] = useState({ items: [value], index: 0, value, }); const selectedPointsRef = useRef([active]); const matrix = useMemo(() => extraSheetToMatrix(value), [value]); const activeColumn = value.columns[active.column]; const activeRow = value.rows[active.row]; const canUndo = history.index > 0; const canRedo = history.index < history.items.length - 1; const sortableIds = useMemo( () => [ ...value.columns.map((column) => columnDragId(column.id)), ...value.rows.map((row) => rowDragId(row.id)), ], [value.columns, value.rows], ); if (history.value !== value) { setHistory( history.items[history.index] === value ? { ...history, value } : { items: [value], index: 0, value }, ); } const commitSheet = useCallback( (next: ExtraSheet) => { if (JSON.stringify(next) === JSON.stringify(value)) return; setHistory((current) => { const items = [ ...current.items.slice(0, current.index + 1), next, ].slice(-SHEET_HISTORY_LIMIT); return { items, index: items.length - 1, value: next }; }); onChange(next); }, [onChange, value], ); const travel = useCallback( (direction: -1 | 1) => { const index = history.index + direction; const next = history.items[index]; if (!next) return; setHistory((current) => ({ ...current, index, value: next })); onChange(next); const point = { row: Math.min(active.row, next.rows.length - 1), column: Math.min(active.column, next.columns.length - 1), }; setActive(point); selectedPointsRef.current = [point]; setSelectedCount(1); window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point), ); }, [active, history, onChange], ); const activateOnly = useCallback((point: Point) => { setActive(point); selectedPointsRef.current = [point]; setSelectedCount(1); window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point)); }, []); const moveRow = useCallback( (from: number, to: number) => { const next = moveExtraSheetRow(value, from, to); if (next === value) return; const point = { ...active, row: movedIndex(active.row, from, to) }; commitSheet(next); activateOnly(point); }, [active, activateOnly, commitSheet, value], ); const moveColumn = useCallback( (from: number, to: number) => { const next = moveExtraSheetColumn(value, from, to); if (next === value) return; const point = { ...active, column: movedIndex(active.column, from, to) }; commitSheet(next); activateOnly(point); }, [active, activateOnly, commitSheet, value], ); const handleDragEnd = useCallback( ({ active: dragged, over }: DragEndEvent) => { if (!over || dragged.id === over.id) return; const draggedId = String(dragged.id); const overId = String(over.id); if (draggedId.startsWith("row:") && overId.startsWith("row:")) { const from = value.rows.findIndex( (row) => rowDragId(row.id) === draggedId, ); const to = value.rows.findIndex((row) => rowDragId(row.id) === overId); if (from !== -1 && to !== -1) moveRow(from, to); } if (draggedId.startsWith("column:") && overId.startsWith("column:")) { const from = value.columns.findIndex( (column) => columnDragId(column.id) === draggedId, ); const to = value.columns.findIndex( (column) => columnDragId(column.id) === overId, ); if (from !== -1 && to !== -1) moveColumn(from, to); } }, [moveColumn, moveRow, value.columns, value.rows], ); const handleSheetKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement; if (target.closest("input, textarea, [contenteditable='true']")) return; const key = event.key.toLowerCase(); const undo = (event.metaKey || event.ctrlKey) && !event.shiftKey && key === "z"; const redo = ((event.metaKey || event.ctrlKey) && event.shiftKey && key === "z") || (event.ctrlKey && !event.shiftKey && key === "y"); if (undo && canUndo) { event.preventDefault(); travel(-1); } else if (redo && canRedo) { event.preventDefault(); travel(1); } }; const normalizedLabel = columnLabel.trim().toLocaleLowerCase(); const labelInvalid = !normalizedLabel || value.columns.some( (column, index) => index !== (renaming ? active.column : -1) && column.label.toLocaleLowerCase() === normalizedLabel, ); const openAddColumn = () => { setRenaming(false); setColumnLabel(nextExtraSheetColumnLabel(value.columns)); setLabelDialogOpen(true); }; const openRenameColumn = useCallback( (columnIndex = active.column) => { const column = value.columns[columnIndex]; if (!column) return; setActive((current) => ({ ...current, column: columnIndex })); setRenaming(true); setColumnLabel(column.label); setLabelDialogOpen(true); }, [active.column, value.columns], ); const saveColumnLabel = () => { if (labelInvalid) return; if (renaming) { commitSheet({ ...value, columns: value.columns.map((column, index) => index === active.column ? { ...column, label: columnLabel.trim() } : column, ), }); } else { const column = value.columns.length; commitSheet( addExtraSheetColumn( value, columnLabel.trim(), createId("extra-sheet-column"), ), ); activateOnly({ ...active, column }); } setLabelDialogOpen(false); }; const openCellEditorAt = useCallback( (point: Point, content?: string) => { setActive(point); const column = value.columns[point.column]; const row = value.rows[point.row]; setCellDraft( normalizeExtraRichText( content ?? (column && row ? (row.cells[column.id] ?? "") : ""), ), ); setOriginalCellDraft( normalizeExtraRichText( content ?? (column && row ? (row.cells[column.id] ?? "") : ""), ), ); setStylePatch({}); setCellDialogOpen(true); }, [value.columns, value.rows], ); const openActiveCellEditor = () => openCellEditorAt(active); const columnIndicator = useCallback( (props: ColumnIndicatorProps) => ( ), [openRenameColumn, value.columns], ); const rowIndicator = useCallback( (props: RowIndicatorProps) => ( ), [value.rows], ); return (
ย้อนกลับ Ctrl/⌘ Z } > ทำซ้ำ Ctrl/⌘ Shift Z } >
{ if ( (event.target as HTMLElement).closest( ".Spreadsheet__active-cell--view", ) ) { event.stopPropagation(); } }} onDoubleClickCapture={(event) => { if ( (event.target as HTMLElement).closest( ".Spreadsheet__cell, .Spreadsheet__active-cell", ) ) { event.preventDefault(); event.stopPropagation(); openCellEditorAt(active); } }} > column.label)} ColumnIndicator={columnIndicator} RowIndicator={rowIndicator} DataViewer={LiteralDataViewer} DataEditor={InlineDataEditor} onActivate={(point) => { setActive(point); setSelectedCount(1); selectedPointsRef.current = [point]; }} onSelect={(nextSelection) => { const range = nextSelection.toRange(matrix); if (range) { selectedPointsRef.current = Array.from(range); setSelectedCount(range.size()); } }} onChange={(next) => commitSheet( matrixToExtraSheet(value, next as ExtraSheetMatrix, createId), ) } />
แก้ไขเซลล์ {selectedCount > 1 ? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์` : `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`} 1} onFormatChange={(patch) => setStylePatch((current) => ({ ...current, ...patch })) } /> {renaming ? "แก้ไขชื่อคอลัมน์" : "เพิ่มคอลัมน์"} ตั้งชื่อที่ใช้อธิบายข้อมูลในคอลัมน์นี้ ชื่อคอลัมน์ setColumnLabel(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") saveColumnLabel(); }} /> { onRemove(); setRemoveOpen(false); }} />
); }