1140 lines
37 KiB
TypeScript
1140 lines
37 KiB
TypeScript
"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,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ClipboardEvent,
|
|
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 { 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,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} 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";
|
|
|
|
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 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;
|
|
alignment?: ExtraSheetAlignment;
|
|
};
|
|
}) {
|
|
return (
|
|
<div
|
|
className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks size-full"
|
|
style={
|
|
cell?.backgroundColor
|
|
? {
|
|
backgroundColor: cell.backgroundColor,
|
|
textAlign: cell.alignment ?? "left",
|
|
}
|
|
: { textAlign: cell?.alignment ?? "left" }
|
|
}
|
|
>
|
|
<RichTextContent
|
|
html={normalizeExtraRichText(String(cell?.value ?? ""))}
|
|
className="extra-sheet-text"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InlineDataEditor({
|
|
cell,
|
|
onChange,
|
|
exitEditMode,
|
|
initialText,
|
|
onReady,
|
|
}: DataEditorProps<{ value: string }> & {
|
|
initialText?: string;
|
|
onReady?: () => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const originalText = useRef(
|
|
extraRichTextToPlainText(String(cell?.value ?? "")),
|
|
);
|
|
const [text, setText] = useState(
|
|
() => initialText ?? extraRichTextToPlainText(String(cell?.value ?? "")),
|
|
);
|
|
const committed = useRef(false);
|
|
const commit = useCallback(() => {
|
|
if (committed.current) return;
|
|
committed.current = true;
|
|
const nextValue = normalizeExtraRichText(text);
|
|
if (text !== originalText.current) {
|
|
onChange({ ...cell, value: nextValue });
|
|
}
|
|
}, [cell, onChange, text]);
|
|
useEffect(() => {
|
|
onReady?.();
|
|
const input = inputRef.current;
|
|
input?.focus();
|
|
input?.setSelectionRange(input.value.length, input.value.length);
|
|
}, [onReady]);
|
|
return (
|
|
<div className="Spreadsheet__data-editor">
|
|
<textarea
|
|
ref={inputRef}
|
|
aria-label="แก้ไขข้อความเซลล์"
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
committed.current = true;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
if (event.key === "Enter" && (event.shiftKey || event.altKey)) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
if (event.key === "Enter" || event.key === "Tab") commit();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditableColumnIndicator({
|
|
id,
|
|
column,
|
|
label,
|
|
selected,
|
|
onSelect,
|
|
onRename,
|
|
onResize,
|
|
width,
|
|
}: ColumnIndicatorProps & {
|
|
id: string;
|
|
onRename: (column: number) => void;
|
|
onResize: (column: number, width: number, commit: boolean) => void;
|
|
width: number;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id,
|
|
strategy: horizontalListSortingStrategy,
|
|
});
|
|
return (
|
|
<th
|
|
ref={setNodeRef}
|
|
data-sheet-column={column}
|
|
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={(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>
|
|
);
|
|
}
|
|
|
|
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}
|
|
data-sheet-row={row}
|
|
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 initialEditTextRef = useRef<string | null>(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 [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(
|
|
() => [
|
|
...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];
|
|
selectedRangeRef.current = { start: point, end: point };
|
|
setSelectedCount(1);
|
|
setSelectionKind("cells");
|
|
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);
|
|
} 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 ||
|
|
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 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}
|
|
/>
|
|
),
|
|
[draftWidths, openRenameColumn, resizeColumn, value.columns],
|
|
);
|
|
const rowIndicator = useCallback(
|
|
(props: RowIndicatorProps) => (
|
|
<SortableRowIndicator
|
|
{...props}
|
|
id={rowDragId(value.rows[props.row].id)}
|
|
/>
|
|
),
|
|
[value.rows],
|
|
);
|
|
const clearInitialEditText = useCallback(() => {
|
|
initialEditTextRef.current = null;
|
|
}, []);
|
|
const dataEditor = useCallback(
|
|
(props: DataEditorProps<{ value: string }>) => (
|
|
<InlineDataEditor
|
|
{...props}
|
|
initialText={initialEditTextRef.current ?? undefined}
|
|
onReady={clearInitialEditText}
|
|
/>
|
|
),
|
|
[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 items-center gap-2 overflow-x-auto pb-1">
|
|
<SimpleTooltip
|
|
text={
|
|
<>
|
|
ย้อนกลับ <Kbd>Ctrl/⌘ Z</Kbd>
|
|
</>
|
|
}
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
disabled={!canUndo}
|
|
aria-label="ย้อนกลับการแก้ไขตาราง"
|
|
onClick={() => travel(-1)}
|
|
>
|
|
<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={
|
|
<>
|
|
ทำซ้ำ <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>
|
|
<label className="relative">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
render={<span />}
|
|
nativeButton={false}
|
|
>
|
|
<PaletteIcon data-icon="inline-start" />
|
|
สีพื้นหลัง
|
|
</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>
|
|
<ToggleGroup
|
|
value={[activeAlignment]}
|
|
variant="outline"
|
|
size="sm"
|
|
spacing={0}
|
|
aria-label="จัดแนวข้อความ"
|
|
onValueChange={(alignments) => {
|
|
const alignment = alignments[0] as ExtraSheetAlignment | undefined;
|
|
if (alignment) {
|
|
commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { alignment }));
|
|
}
|
|
}}
|
|
>
|
|
<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"
|
|
size="sm"
|
|
onClick={() => setRemoveOpen(true)}
|
|
>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
ลบตาราง
|
|
</Button>
|
|
</div>
|
|
|
|
<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) => {
|
|
if (
|
|
(event.target as HTMLElement).closest(
|
|
".Spreadsheet__data-editor",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const startsTyping =
|
|
event.key.length === 1 &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
!event.altKey;
|
|
if (event.key !== "F2" && !startsTyping) return;
|
|
event.preventDefault();
|
|
initialEditTextRef.current = startsTyping
|
|
? event.key
|
|
: null;
|
|
const root = event.currentTarget as HTMLElement;
|
|
root
|
|
.querySelector<HTMLElement>(".Spreadsheet__active-cell--view")
|
|
?.click();
|
|
}}
|
|
onActivate={(point) => {
|
|
setActive(point);
|
|
setSelectedCount(1);
|
|
selectedPointsRef.current = [point];
|
|
selectedRangeRef.current = { start: point, end: point };
|
|
setSelectionKind("cells");
|
|
}}
|
|
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) =>
|
|
commitSheet(
|
|
matrixToExtraSheet(value, next as ExtraSheetMatrix, createId),
|
|
)
|
|
}
|
|
/>
|
|
</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>
|
|
<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>
|
|
);
|
|
}
|