diff --git a/app/globals.css b/app/globals.css
index c48014b..10d6dea 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -159,9 +159,68 @@
color: var(--accent-foreground);
}
-.extra-sheet-grid .Spreadsheet__data-viewer,
-.extra-sheet-grid .Spreadsheet__data-viewer * {
- color: #000 !important;
+.extra-sheet-grid .Spreadsheet__table {
+ width: max-content;
+ min-width: 100%;
+}
+
+.extra-sheet-grid .Spreadsheet__cell,
+.extra-sheet-grid .Spreadsheet__header {
+ min-width: 0;
+}
+
+.extra-sheet-grid .Spreadsheet__cell {
+ height: auto;
+ min-height: 2.25rem;
+ max-height: none;
+ vertical-align: top;
+}
+
+.extra-sheet-grid .Spreadsheet__table > tbody > tr:first-child > th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+
+.extra-sheet-grid .Spreadsheet__table > tbody > tr > th:first-child {
+ position: sticky;
+ left: 0;
+ z-index: 1;
+}
+
+.extra-sheet-grid .Spreadsheet__table > tbody > tr:first-child > th:first-child {
+ z-index: 3;
+}
+
+.extra-sheet-row-number-column {
+ width: 6.5rem;
+}
+
+.extra-sheet-column-resizer {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-end: -0.25rem;
+ width: 0.5rem;
+ cursor: col-resize;
+ touch-action: none;
+}
+
+.extra-sheet-grid .Spreadsheet__data-viewer {
+ min-height: 2.25rem;
+ white-space: pre-wrap;
+}
+
+.extra-sheet-text {
+ --tw-prose-body: #000;
+ --tw-prose-headings: #000;
+ --tw-prose-lead: #000;
+ --tw-prose-links: #000;
+ --tw-prose-bold: #000;
+ --tw-prose-counters: #000;
+ --tw-prose-bullets: #000;
+ --tw-prose-quotes: #000;
+ --tw-prose-code: #000;
+ color: #000;
}
.extra-sheet-grid .Spreadsheet__data-editor textarea {
diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx
index a10b486..6184517 100644
--- a/components/admin/extra-sheet-editor.tsx
+++ b/components/admin/extra-sheet-editor.tsx
@@ -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 (
);
@@ -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 (
onRename(column)}
+ onClick={(event) => onSelect(column, event.shiftKey)}
+ onDoubleClick={() => onRename(column)}
>
{label}
+ {
+ 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 });
+ }}
+ />
|
);
}
@@ -258,6 +359,7 @@ function SortableRowIndicator({
return (
("cells");
+ const [draftWidths, setDraftWidths] = useState>({});
const [history, setHistory] = useState({
items: [value],
index: 0,
value,
});
const selectedPointsRef = useRef([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) => {
+ 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) => {
+ if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return;
+ handleCopy(event);
+ clearContents();
+ };
+ const handlePaste = (event: ClipboardEvent) => {
+ 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) => (
),
- [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) => (
+
+
+ {!hideColumnIndicators ? : null}
+ {Array.from({ length: columns }, (_, index) => (
+
+ ))}
+
+ {children}
+
+ ),
+ [draftWidths, value.columns],
+ );
+ const activeFormats = [
+ / Boolean(format));
+ const activeTextColor = activeValue.match(/color:\s*(#[0-9A-Fa-f]{6})/i)?.[1]
+ ?? "#111111";
return (
-
+
@@ -554,6 +794,55 @@ export function ExtraSheetEditor({
+ {
+ 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);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -603,7 +892,7 @@ export function ExtraSheetEditor({
nativeButton={false}
>
- สีพื้นหลัง {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
+ สีพื้นหลัง
-
+
+
+
+
+
+
+
-
-
-
-
+
+ {selectedCount > 1
+ ? `${selectedCount} cells`
+ : `${spreadsheetColumnName(active.column)}${active.row + 1}`}
+
+ {
+ 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();
+ }
+ }}
+ />
+
+
+
+
+
+
+ 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),
)
}
- />
-
-
-
+ />
+
+
+
+
+
+ void cutSelection()}>
+ ตัด Ctrl/⌘ X
+
+ void copySelection()}>
+ คัดลอก Ctrl/⌘ C
+
+ void pasteFromClipboard()}>
+ วาง Ctrl/⌘ V
+
+
+
+ {selectionKind === "rows" ? (
+
+ insertRowAt(active.row)}>เพิ่มแถวด้านบน
+ insertRowAt(active.row + 1)}>เพิ่มแถวด้านล่าง
+
+ ลบแถว
+
+
+ ) : selectionKind === "columns" ? (
+
+ insertColumnAt(active.column)}>เพิ่มคอลัมน์ด้านซ้าย
+ insertColumnAt(active.column + 1)}>เพิ่มคอลัมน์ด้านขวา
+ openRenameColumn()}>เปลี่ยนชื่อคอลัมน์
+
+ ลบคอลัมน์
+
+
+ ) : (
+
+ ล้างเนื้อหา
+ ล้างรูปแบบ
+
+ )}
+
+
|