Files
buzz-sheet/components/admin/extra-sheet-editor.tsx

629 lines
22 KiB
TypeScript

"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 (
<div
className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks size-full"
style={cell?.backgroundColor ? { backgroundColor: cell.backgroundColor } : undefined}
>
<RichTextContent html={normalizeExtraRichText(String(cell?.value ?? ""))} />
</div>
);
}
function InlineDataEditor({
cell,
onChange,
}: DataEditorProps<{ value: string }>) {
const inputRef = useRef<HTMLInputElement>(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 (
<div className="Spreadsheet__data-editor">
<input
ref={inputRef}
type="text"
aria-label="แก้ไขข้อความเซลล์"
value={text}
onChange={(event) => onChange({ ...cell, value: event.target.value })}
/>
</div>
);
}
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 (
<th
ref={setNodeRef}
className={cn("Spreadsheet__header", selected && "Spreadsheet__header--selected", isDragging && "opacity-60")}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
aria-label={`ลากเพื่อย้าย ${String(label ?? `คอลัมน์ ${column + 1}`)}`}
{...attributes}
{...listeners}
>
<GripHorizontalIcon />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 flex-1"
onClick={() => onRename(column)}
>
{label}
</Button>
</th>
);
}
function SortableRowIndicator({ id, row, label, selected, onSelect }: RowIndicatorProps & { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
strategy: verticalListSortingStrategy,
});
return (
<th
ref={setNodeRef}
className={cn("Spreadsheet__header", selected && "Spreadsheet__header--selected", isDragging && "opacity-60")}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
aria-label={`ลากเพื่อย้ายแถว ${row + 1}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 flex-1"
onClick={(event) => onSelect(row, event.shiftKey)}
>
{label ?? row + 1}
</Button>
</th>
);
}
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<SpreadsheetRef>(null);
const [active, setActive] = useState<Point>({ 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<RichTextStylePatch>({});
const [selectedCount, setSelectedCount] = useState(1);
const [history, setHistory] = useState<SheetHistory>({ items: [value], index: 0, value });
const selectedPointsRef = useRef<Point[]>([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<HTMLDivElement>) => {
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) => (
<EditableColumnIndicator
{...props}
id={columnDragId(value.columns[props.column].id)}
onRename={openRenameColumn}
/>
), [openRenameColumn, value.columns]);
const rowIndicator = useCallback((props: RowIndicatorProps) => (
<SortableRowIndicator {...props} id={rowDragId(value.rows[props.row].id)} />
), [value.rows]);
return (
<div
className="flex min-w-0 flex-col gap-3"
aria-label="ตารางข้อมูลเสริม"
onKeyDown={handleSheetKeyDown}
>
<div className="flex flex-wrap items-center gap-2">
<SimpleTooltip text={<>ย้อนกลับ <Kbd>Ctrl/⌘ Z</Kbd></>}>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={!canUndo}
aria-label="ย้อนกลับการแก้ไขตาราง"
onClick={() => travel(-1)}
>
<Undo2Icon />
</Button>
</SimpleTooltip>
<SimpleTooltip text={<>ทำซ้ำ <Kbd>Ctrl/⌘ Shift Z</Kbd></>}>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={!canRedo}
aria-label="ทำซ้ำการแก้ไขตาราง"
onClick={() => travel(1)}
>
<Redo2Icon />
</Button>
</SimpleTooltip>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const row = value.rows.length;
commitSheet(addExtraSheetRow(value, createId("extra-sheet-row")));
activateOnly({ ...active, row });
}}
>
<PlusIcon data-icon="inline-start" />
เพิ่มแถว
</Button>
<Button type="button" variant="outline" size="sm" onClick={openAddColumn}>
<PlusIcon data-icon="inline-start" />
เพิ่มคอลัมน์
</Button>
<Button type="button" variant="outline" size="sm" onClick={openActiveCellEditor}>
<PencilIcon data-icon="inline-start" />
แก้ไข {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
</Button>
<label className="relative">
<Button type="button" variant="outline" size="sm" render={<span />} nativeButton={false}>
<PaletteIcon data-icon="inline-start" />
สีพื้นหลัง {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
</Button>
<input
type="color"
aria-label="เลือกสีพื้นหลังเซลล์"
className="absolute inset-0 cursor-pointer opacity-0"
value={activeRow?.backgrounds?.[activeColumn?.id ?? ""] ?? DEFAULT_CELL_BACKGROUND}
onChange={(event) => {
commitSheet(updateExtraSheetCells(
value,
selectedPointsRef.current,
{ backgroundColor: event.target.value },
));
}}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { backgroundColor: null }))}
>
ล้างสีพื้นหลัง
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoveOpen(true)}>
<Trash2Icon data-icon="inline-start" />
ลบตาราง
</Button>
</div>
<div
className="overflow-x-auto rounded-lg border"
onClickCapture={(event) => {
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);
}
}}
>
<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}
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,
))}
/>
</SortableContext>
</DndContext>
</div>
<Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขเซลล์</DialogTitle>
<DialogDescription>
{selectedCount > 1
? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์`
: `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`}
</DialogDescription>
</DialogHeader>
<RichTextEditor
value={cellDraft}
onChange={setCellDraft}
showBubbleMenu={false}
formatWholeContent={selectedCount > 1}
onFormatChange={(patch) => setStylePatch((current) => ({ ...current, ...patch }))}
/>
<DialogFooter showCloseButton>
<Button type="button" onClick={() => {
const contentUnchanged = richTextContentSignature(cellDraft)
=== richTextContentSignature(originalCellDraft);
if (selectedCount > 1 && contentUnchanged) {
if (Object.keys(stylePatch).length > 0) {
commitSheet(styleExtraSheetCells(value, selectedPointsRef.current, stylePatch));
}
} else {
commitSheet(selectedCount > 1
? updateExtraSheetCells(value, selectedPointsRef.current, { value: cellDraft })
: updateExtraSheetCell(value, active.row, active.column, cellDraft));
}
setCellDialogOpen(false);
}}>บันทึกเซลล์</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{renaming ? "แก้ไขชื่อคอลัมน์" : "เพิ่มคอลัมน์"}</DialogTitle>
<DialogDescription>ตั้งชื่อที่ใช้อธิบายข้อมูลในคอลัมน์นี้</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={labelInvalid || undefined}>
<FieldLabel htmlFor="extra-sheet-column-label">ชื่อคอลัมน์</FieldLabel>
<Input
id="extra-sheet-column-label"
value={columnLabel}
aria-invalid={labelInvalid || undefined}
autoFocus
onChange={(event) => setColumnLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") saveColumnLabel();
}}
/>
</Field>
</FieldGroup>
<DialogFooter showCloseButton>
<Button type="button" disabled={labelInvalid} onClick={saveColumnLabel}>
{renaming ? "บันทึกชื่อ" : "เพิ่มคอลัมน์"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
open={removeOpen}
onOpenChange={setRemoveOpen}
title="ลบตารางนี้?"
description="คอลัมน์ แถว และข้อมูลในเซลล์ทั้งหมดจะถูกลบออกจากการ์ด"
confirmLabel="ยืนยันลบตาราง"
showSuccessToast={false}
onConfirm={() => {
onRemove();
setRemoveOpen(false);
}}
/>
</div>
);
}