feat : update
This commit is contained in:
+62
-3
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "@/lib/guides/talents";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import { DEFAULT_EXTRA_SHEET_COLUMN_WIDTH } from "@/lib/guides/extra-sheet";
|
||||
import { publicGuideSections } from "@/lib/guides/public-sections";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1363,8 +1364,21 @@ function ExtraSectionContent({
|
||||
>
|
||||
<Table
|
||||
className={cn(exporting && "table-fixed")}
|
||||
style={exporting ? undefined : { minWidth: `${row.sheet.columns.length * 140}px` }}
|
||||
style={{
|
||||
minWidth: `${row.sheet.columns.reduce(
|
||||
(total, column) => total + (column.width ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH),
|
||||
0,
|
||||
)}px`,
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
{row.sheet.columns.map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={{ width: column.width ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH }}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{row.sheet.columns.map((column) => (
|
||||
@@ -1381,11 +1395,15 @@ function ExtraSectionContent({
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className="border-l first:border-l-0 whitespace-normal"
|
||||
style={sheetRow.backgrounds?.[column.id]
|
||||
? { backgroundColor: sheetRow.backgrounds[column.id] }
|
||||
: undefined}
|
||||
style={{
|
||||
backgroundColor: sheetRow.backgrounds?.[column.id],
|
||||
textAlign: sheetRow.alignments?.[column.id] ?? "left",
|
||||
}}
|
||||
>
|
||||
<RichTextContent html={normalizeExtraRichText(sheetRow.cells[column.id] ?? "")} />
|
||||
<RichTextContent
|
||||
html={normalizeExtraRichText(sheetRow.cells[column.id] ?? "")}
|
||||
className="extra-sheet-text"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 4,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ContextMenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn("max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ContextMenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuContent>) {
|
||||
return (
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className="shadow-lg"
|
||||
side="right"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -3,15 +3,20 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addExtraSheetColumn,
|
||||
addExtraSheetRow,
|
||||
clearExtraSheetCellFormatting,
|
||||
createExtraSheet,
|
||||
deleteExtraSheetColumn,
|
||||
deleteExtraSheetRow,
|
||||
extraSheetSchema,
|
||||
extraSheetRangeToTsv,
|
||||
extraSheetToMatrix,
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
normalizeExtraSheetRichText,
|
||||
parseExtraSheetTsv,
|
||||
pasteExtraSheetText,
|
||||
resizeExtraSheetColumn,
|
||||
styleExtraSheetCells,
|
||||
updateExtraSheetCell,
|
||||
updateExtraSheetCells,
|
||||
@@ -152,6 +157,57 @@ describe("Extra sheet", () => {
|
||||
expect(extraSheetToMatrix(legacy)[0][0]).toEqual({
|
||||
value: "",
|
||||
backgroundColor: undefined,
|
||||
alignment: undefined,
|
||||
});
|
||||
|
||||
const parsed = extraSheetSchema.parse(legacy);
|
||||
expect(parsed.columns[0].width).toBe(140);
|
||||
expect(parsed.rows[0].alignments).toEqual({});
|
||||
});
|
||||
|
||||
it("persists bounded column widths and cell alignment", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const resized = resizeExtraSheetColumn(sheet, 0, 999);
|
||||
const aligned = updateExtraSheetCells(resized, [{ row: 0, column: 0 }], {
|
||||
alignment: "center",
|
||||
backgroundColor: "#123456",
|
||||
});
|
||||
|
||||
expect(aligned.columns[0].width).toBe(480);
|
||||
expect(extraSheetToMatrix(aligned)[0][0]).toMatchObject({
|
||||
alignment: "center",
|
||||
backgroundColor: "#123456",
|
||||
});
|
||||
expect(extraSheetSchema.safeParse(aligned).success).toBe(true);
|
||||
});
|
||||
|
||||
it("clears visual formatting without clearing cell content", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
const columnId = sheet.columns[0].id;
|
||||
sheet.rows[0].cells[columnId] = '<p><strong><span style="color: #FF0000">Alpha</span></strong></p>';
|
||||
sheet.rows[0].backgrounds[columnId] = "#123456";
|
||||
sheet.rows[0].alignments[columnId] = "right";
|
||||
|
||||
const cleared = clearExtraSheetCellFormatting(sheet, [{ row: 0, column: 0 }]);
|
||||
expect(cleared.rows[0].cells[columnId]).toBe("<p>Alpha</p>");
|
||||
expect(cleared.rows[0].backgrounds).toEqual({});
|
||||
expect(cleared.rows[0].alignments).toEqual({});
|
||||
});
|
||||
|
||||
it("copies plain TSV and pastes multiline cells while expanding the grid", () => {
|
||||
const createId = idFactory();
|
||||
const sheet = createExtraSheet(createId);
|
||||
const firstColumn = sheet.columns[0].id;
|
||||
sheet.rows[0].cells[firstColumn] = "<p>Alpha<br>Beta</p>";
|
||||
const copied = extraSheetRangeToTsv(sheet, { row: 0, column: 0 }, { row: 0, column: 0 });
|
||||
|
||||
expect(copied).toBe('"Alpha\nBeta"');
|
||||
expect(parseExtraSheetTsv('"Alpha\nBeta"\tGamma')).toEqual([["Alpha\nBeta", "Gamma"]]);
|
||||
|
||||
const pasted = pasteExtraSheetText(sheet, { row: 2, column: 2 }, '"Alpha\nBeta"\tGamma', createId);
|
||||
expect(pasted.rows).toHaveLength(3);
|
||||
expect(pasted.columns).toHaveLength(4);
|
||||
expect(pasted.rows[2].cells[pasted.columns[2].id]).toBe("<p>Alpha<br>Beta</p>");
|
||||
expect(pasted.rows[2].cells[pasted.columns[3].id]).toBe("<p>Gamma</p>");
|
||||
});
|
||||
});
|
||||
|
||||
+195
-10
@@ -2,21 +2,31 @@ import * as z from "zod";
|
||||
|
||||
import {
|
||||
applyRichTextStylePatch,
|
||||
clearRichTextFormatting,
|
||||
extraRichTextToPlainText,
|
||||
normalizeExtraRichText,
|
||||
type RichTextStylePatch,
|
||||
} from "@/lib/rich-text";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
export const DEFAULT_EXTRA_SHEET_COLUMN_WIDTH = 140;
|
||||
export const MIN_EXTRA_SHEET_COLUMN_WIDTH = 96;
|
||||
export const MAX_EXTRA_SHEET_COLUMN_WIDTH = 480;
|
||||
export const extraSheetAlignmentSchema = z.enum(["left", "center", "right"]);
|
||||
export type ExtraSheetAlignment = z.infer<typeof extraSheetAlignmentSchema>;
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
columns: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
label: z.string().trim().min(1).max(120),
|
||||
width: z.number().int().min(MIN_EXTRA_SHEET_COLUMN_WIDTH).max(MAX_EXTRA_SHEET_COLUMN_WIDTH)
|
||||
.default(DEFAULT_EXTRA_SHEET_COLUMN_WIDTH),
|
||||
})).min(1),
|
||||
rows: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
cells: z.record(z.string(), z.string().max(10_000)),
|
||||
backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}),
|
||||
alignments: z.record(z.string(), extraSheetAlignmentSchema).default({}),
|
||||
})).min(1),
|
||||
}).superRefine((sheet, context) => {
|
||||
const columnIds = new Set(sheet.columns.map((column) => column.id));
|
||||
@@ -32,7 +42,11 @@ export const extraSheetSchema = z.strictObject({
|
||||
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
sheet.rows.forEach((row, rowIndex) => {
|
||||
[...Object.keys(row.cells), ...Object.keys(row.backgrounds ?? {})].forEach((columnId) => {
|
||||
[
|
||||
...Object.keys(row.cells),
|
||||
...Object.keys(row.backgrounds ?? {}),
|
||||
...Object.keys(row.alignments ?? {}),
|
||||
].forEach((columnId) => {
|
||||
if (!columnIds.has(columnId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
@@ -46,7 +60,11 @@ export const extraSheetSchema = z.strictObject({
|
||||
|
||||
export type ExtraSheet = z.output<typeof extraSheetSchema>;
|
||||
|
||||
export type ExtraSheetCell = { value: string; backgroundColor?: string };
|
||||
export type ExtraSheetCell = {
|
||||
value: string;
|
||||
backgroundColor?: string;
|
||||
alignment?: ExtraSheetAlignment;
|
||||
};
|
||||
export type ExtraSheetMatrix = Array<Array<ExtraSheetCell | undefined>>;
|
||||
type SheetPoint = { row: number; column: number };
|
||||
|
||||
@@ -61,6 +79,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
|
||||
const columns = [1, 2].map((index) => ({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: `คอลัมน์ ${index}`,
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
}));
|
||||
return {
|
||||
columns,
|
||||
@@ -68,6 +87,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe
|
||||
id: createId("extra-sheet-row"),
|
||||
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -77,6 +97,7 @@ export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
|
||||
sheet.columns.map((column) => ({
|
||||
value: row.cells[column.id] ?? "",
|
||||
backgroundColor: row.backgrounds?.[column.id],
|
||||
alignment: row.alignments?.[column.id],
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -95,6 +116,7 @@ export function matrixToExtraSheet(
|
||||
columns.push({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: nextExtraSheetColumnLabel(columns),
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
});
|
||||
}
|
||||
const rowCount = Math.max(sheet.rows.length, matrix.length);
|
||||
@@ -109,6 +131,11 @@ export function matrixToExtraSheet(
|
||||
?? sheet.rows[rowIndex]?.backgrounds?.[column.id];
|
||||
return backgroundColor ? [[column.id, backgroundColor]] : [];
|
||||
})),
|
||||
alignments: Object.fromEntries(columns.flatMap((column, columnIndex) => {
|
||||
const alignment = matrix[rowIndex]?.[columnIndex]?.alignment
|
||||
?? sheet.rows[rowIndex]?.alignments?.[column.id];
|
||||
return alignment ? [[column.id, alignment]] : [];
|
||||
})),
|
||||
}));
|
||||
return { columns, rows };
|
||||
}
|
||||
@@ -118,20 +145,65 @@ export function addExtraSheetColumn(
|
||||
label: string,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
return insertExtraSheetColumn(sheet, sheet.columns.length, label, id);
|
||||
}
|
||||
|
||||
export function insertExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
label: string,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.columns.length));
|
||||
const columns = [...sheet.columns];
|
||||
columns.splice(position, 0, {
|
||||
id,
|
||||
label,
|
||||
width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
});
|
||||
return {
|
||||
columns: [...sheet.columns, { id, label }],
|
||||
columns,
|
||||
rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
|
||||
};
|
||||
}
|
||||
|
||||
export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
|
||||
return insertExtraSheetRow(sheet, sheet.rows.length, id);
|
||||
}
|
||||
|
||||
export function insertExtraSheetRow(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
const position = Math.max(0, Math.min(index, sheet.rows.length));
|
||||
const rows = [...sheet.rows];
|
||||
rows.splice(position, 0, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
alignments: {},
|
||||
});
|
||||
return {
|
||||
...sheet,
|
||||
rows: [...sheet.rows, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
backgrounds: {},
|
||||
}],
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
index: number,
|
||||
width: number,
|
||||
): ExtraSheet {
|
||||
if (!sheet.columns[index]) return sheet;
|
||||
const normalized = Math.round(Math.max(
|
||||
MIN_EXTRA_SHEET_COLUMN_WIDTH,
|
||||
Math.min(MAX_EXTRA_SHEET_COLUMN_WIDTH, width),
|
||||
));
|
||||
return {
|
||||
...sheet,
|
||||
columns: sheet.columns.map((column, columnIndex) =>
|
||||
columnIndex === index ? { ...column, width: normalized } : column),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +241,7 @@ export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraS
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
|
||||
backgrounds: Object.fromEntries(Object.entries(row.backgrounds ?? {}).filter(([key]) => key !== columnId)),
|
||||
alignments: Object.fromEntries(Object.entries(row.alignments ?? {}).filter(([key]) => key !== columnId)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -198,7 +271,11 @@ export function updateExtraSheetCell(
|
||||
export function updateExtraSheetCells(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
update: { value?: string; backgroundColor?: string | null },
|
||||
update: {
|
||||
value?: string;
|
||||
backgroundColor?: string | null;
|
||||
alignment?: ExtraSheetAlignment | null;
|
||||
},
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
return {
|
||||
@@ -206,13 +283,121 @@ export function updateExtraSheetCells(
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
sheet.columns.forEach((column, columnIndex) => {
|
||||
if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
|
||||
if (update.value !== undefined) cells[column.id] = update.value;
|
||||
if (update.backgroundColor === null) delete backgrounds[column.id];
|
||||
else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor;
|
||||
if (update.alignment === null) delete alignments[column.id];
|
||||
else if (update.alignment !== undefined) alignments[column.id] = update.alignment;
|
||||
});
|
||||
return { ...row, cells, backgrounds };
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearExtraSheetCellFormatting(
|
||||
sheet: ExtraSheet,
|
||||
points: Iterable<SheetPoint>,
|
||||
): ExtraSheet {
|
||||
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
|
||||
return {
|
||||
...sheet,
|
||||
rows: sheet.rows.map((row, rowIndex) => {
|
||||
const cells = { ...row.cells };
|
||||
const backgrounds = { ...(row.backgrounds ?? {}) };
|
||||
const alignments = { ...(row.alignments ?? {}) };
|
||||
sheet.columns.forEach((column, columnIndex) => {
|
||||
if (!selected.has(`${rowIndex}:${columnIndex}`)) return;
|
||||
cells[column.id] = clearRichTextFormatting(cells[column.id] ?? "");
|
||||
delete backgrounds[column.id];
|
||||
delete alignments[column.id];
|
||||
});
|
||||
return { ...row, cells, backgrounds, alignments };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function quoteTsvValue(value: string): string {
|
||||
return /[\t\r\n"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
||||
}
|
||||
|
||||
export function extraSheetRangeToTsv(
|
||||
sheet: ExtraSheet,
|
||||
start: SheetPoint,
|
||||
end: SheetPoint,
|
||||
): string {
|
||||
const top = Math.min(start.row, end.row);
|
||||
const bottom = Math.max(start.row, end.row);
|
||||
const left = Math.min(start.column, end.column);
|
||||
const right = Math.max(start.column, end.column);
|
||||
return sheet.rows.slice(top, bottom + 1).map((row) =>
|
||||
sheet.columns.slice(left, right + 1).map((column) =>
|
||||
quoteTsvValue(extraRichTextToPlainText(row.cells[column.id] ?? "")),
|
||||
).join("\t"),
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
export function parseExtraSheetTsv(value: string): string[][] {
|
||||
const rows: string[][] = [[]];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === '"') {
|
||||
if (quoted && value[index + 1] === '"') {
|
||||
field += '"';
|
||||
index += 1;
|
||||
} else quoted = !quoted;
|
||||
} else if (character === "\t" && !quoted) {
|
||||
rows.at(-1)!.push(field);
|
||||
field = "";
|
||||
} else if ((character === "\n" || character === "\r") && !quoted) {
|
||||
if (character === "\r" && value[index + 1] === "\n") index += 1;
|
||||
rows.at(-1)!.push(field);
|
||||
rows.push([]);
|
||||
field = "";
|
||||
} else field += character;
|
||||
}
|
||||
rows.at(-1)!.push(field);
|
||||
return rows.length > 1 && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === ""
|
||||
? rows.slice(0, -1)
|
||||
: rows;
|
||||
}
|
||||
|
||||
export function pasteExtraSheetText(
|
||||
sheet: ExtraSheet,
|
||||
start: SheetPoint,
|
||||
text: string,
|
||||
createId: (prefix: string) => string,
|
||||
): ExtraSheet {
|
||||
const pasted = parseExtraSheetTsv(text);
|
||||
if (!pasted.length) return sheet;
|
||||
let next = sheet;
|
||||
const requiredRows = start.row + pasted.length;
|
||||
const requiredColumns = start.column + Math.max(...pasted.map((row) => row.length));
|
||||
while (next.rows.length < requiredRows) {
|
||||
next = addExtraSheetRow(next, createId("extra-sheet-row"));
|
||||
}
|
||||
while (next.columns.length < requiredColumns) {
|
||||
next = addExtraSheetColumn(
|
||||
next,
|
||||
nextExtraSheetColumnLabel(next.columns),
|
||||
createId("extra-sheet-column"),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
rows: next.rows.map((row, rowIndex) => {
|
||||
const pastedRow = pasted[rowIndex - start.row];
|
||||
if (!pastedRow) return row;
|
||||
const cells = { ...row.cells };
|
||||
pastedRow.forEach((cell, offset) => {
|
||||
const column = next.columns[start.column + offset];
|
||||
if (column) cells[column.id] = normalizeExtraRichText(cell);
|
||||
});
|
||||
return { ...row, cells };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,3 +184,14 @@ export function applyRichTextStylePatch(
|
||||
|
||||
return sanitizeRichTextHtml(result);
|
||||
}
|
||||
|
||||
/** Removes visual marks from rich text while preserving its structure and links. */
|
||||
export function clearRichTextFormatting(value: string): string {
|
||||
return applyRichTextStylePatch(value, {
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
color: null,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user