feat : update excel
CI / Verify (push) Successful in 1m11s
CI / Build immutable images and deploy (push) Successful in 2m0s

This commit is contained in:
2026-09-05 19:30:50 +07:00 Unverified
parent 2d651fbe56
commit 66238795bf
6 changed files with 529 additions and 67 deletions
+336 -58
View File
@@ -6,18 +6,48 @@ import {
ArrowRightIcon, ArrowRightIcon,
ArrowUpIcon, ArrowUpIcon,
Columns3Icon, Columns3Icon,
GripHorizontalIcon,
GripVerticalIcon,
PlusIcon, PlusIcon,
PencilIcon, PencilIcon,
PaletteIcon, PaletteIcon,
Redo2Icon,
Rows3Icon, Rows3Icon,
Trash2Icon, Trash2Icon,
Undo2Icon,
} from "lucide-react"; } from "lucide-react";
import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point } from "react-spreadsheet"; import Spreadsheet, {
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type ColumnIndicatorProps,
type DataEditorProps,
type Point,
type RowIndicatorProps,
type SpreadsheetRef,
} from "react-spreadsheet";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
rectSortingStrategy,
sortableKeyboardCoordinates,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { RichTextContent } from "@/components/rich-text-content"; import { RichTextContent } from "@/components/rich-text-content";
import { RichTextEditor } from "@/components/rich-text-editor"; import { RichTextEditor } from "@/components/rich-text-editor";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -38,6 +68,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
import { import {
addExtraSheetColumn, addExtraSheetColumn,
addExtraSheetRow, addExtraSheetRow,
@@ -48,15 +79,47 @@ import {
moveExtraSheetColumn, moveExtraSheetColumn,
moveExtraSheetRow, moveExtraSheetRow,
nextExtraSheetColumnLabel, nextExtraSheetColumnLabel,
styleExtraSheetCells,
updateExtraSheetCell, updateExtraSheetCell,
updateExtraSheetCells, updateExtraSheetCells,
type ExtraSheet, type ExtraSheet,
type ExtraSheetMatrix, type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet"; } from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text"; import {
normalizeExtraRichText,
richTextContentSignature,
type RichTextStylePatch,
} from "@/lib/rich-text";
import { cn } from "@/lib/utils";
type DeleteTarget = "column" | "row" | null; type DeleteTarget = "column" | "row" | null;
interface SheetHistory {
items: ExtraSheet[];
index: number;
value: ExtraSheet;
}
const DEFAULT_CELL_BACKGROUND = "#16A34A"; const DEFAULT_CELL_BACKGROUND = "#16A34A";
const SHEET_HISTORY_LIMIT = 80;
const columnDragId = (id: string) => `column:${id}`;
const rowDragId = (id: string) => `row:${id}`;
const sameAxisCollision: CollisionDetection = (args) => {
const axis = String(args.active.id).split(":", 1)[0];
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container) => String(container.id).startsWith(`${axis}:`),
),
});
};
function movedIndex(current: number, from: number, to: number): number {
if (current === from) return to;
if (from < to && current > from && current <= to) return current - 1;
if (to < from && current >= to && current < from) return current + 1;
return current;
}
function LiteralDataViewer({ cell }: { cell?: { value?: unknown; backgroundColor?: string } }) { function LiteralDataViewer({ cell }: { cell?: { value?: unknown; backgroundColor?: string } }) {
return ( return (
@@ -86,18 +149,38 @@ function DialogDataEditor({
} }
function EditableColumnIndicator({ function EditableColumnIndicator({
id,
column, column,
label, label,
selected, selected,
onRename, onRename,
}: ColumnIndicatorProps & { onRename: (column: number) => void }) { }: ColumnIndicatorProps & { id: string; onRename: (column: number) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
strategy: horizontalListSortingStrategy,
});
return ( return (
<th className={selected ? "Spreadsheet__header Spreadsheet__header--selected" : "Spreadsheet__header"}> <th
ref={setNodeRef}
className={cn("Spreadsheet__header", selected && "Spreadsheet__header--selected", isDragging && "opacity-60")}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
aria-label={`ลากเพื่อย้าย ${String(label ?? `คอลัมน์ ${column + 1}`)}`}
{...attributes}
{...listeners}
>
<GripHorizontalIcon />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full" className="min-w-0 flex-1"
onClick={() => onRename(column)} onClick={() => onRename(column)}
> >
{label} {label}
@@ -106,6 +189,41 @@ function EditableColumnIndicator({
); );
} }
function SortableRowIndicator({ id, row, label, selected, onSelect }: RowIndicatorProps & { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
strategy: verticalListSortingStrategy,
});
return (
<th
ref={setNodeRef}
className={cn("Spreadsheet__header", selected && "Spreadsheet__header--selected", isDragging && "opacity-60")}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
aria-label={`ลากเพื่อย้ายแถว ${row + 1}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 flex-1"
onClick={(event) => onSelect(row, event.shiftKey)}
>
{label ?? row + 1}
</Button>
</th>
);
}
export function ExtraSheetEditor({ export function ExtraSheetEditor({
value, value,
createId, createId,
@@ -117,6 +235,11 @@ export function ExtraSheetEditor({
onChange: (value: ExtraSheet) => void; onChange: (value: ExtraSheet) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const spreadsheetRef = useRef<SpreadsheetRef>(null);
const [active, setActive] = useState<Point>({ row: 0, column: 0 }); const [active, setActive] = useState<Point>({ row: 0, column: 0 });
const [labelDialogOpen, setLabelDialogOpen] = useState(false); const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@@ -125,11 +248,107 @@ export function ExtraSheetEditor({
const [removeOpen, setRemoveOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false);
const [cellDialogOpen, setCellDialogOpen] = useState(false); const [cellDialogOpen, setCellDialogOpen] = useState(false);
const [cellDraft, setCellDraft] = useState(""); const [cellDraft, setCellDraft] = useState("");
const [originalCellDraft, setOriginalCellDraft] = useState("");
const [stylePatch, setStylePatch] = useState<RichTextStylePatch>({});
const [selectedCount, setSelectedCount] = useState(1); const [selectedCount, setSelectedCount] = useState(1);
const [history, setHistory] = useState<SheetHistory>({ items: [value], index: 0, value });
const selectedPointsRef = useRef<Point[]>([active]); const selectedPointsRef = useRef<Point[]>([active]);
const matrix = useMemo(() => extraSheetToMatrix(value), [value]); const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
const activeColumn = value.columns[active.column]; const activeColumn = value.columns[active.column];
const activeRow = value.rows[active.row]; const activeRow = value.rows[active.row];
const canUndo = history.index > 0;
const canRedo = history.index < history.items.length - 1;
const sortableIds = useMemo(() => [
...value.columns.map((column) => columnDragId(column.id)),
...value.rows.map((row) => rowDragId(row.id)),
], [value.columns, value.rows]);
if (history.value !== value) {
setHistory(history.items[history.index] === value
? { ...history, value }
: { items: [value], index: 0, value });
}
const commitSheet = useCallback((next: ExtraSheet) => {
if (JSON.stringify(next) === JSON.stringify(value)) return;
setHistory((current) => {
const items = [...current.items.slice(0, current.index + 1), next]
.slice(-SHEET_HISTORY_LIMIT);
return { items, index: items.length - 1, value: next };
});
onChange(next);
}, [onChange, value]);
const travel = useCallback((direction: -1 | 1) => {
const index = history.index + direction;
const next = history.items[index];
if (!next) return;
setHistory((current) => ({ ...current, index, value: next }));
onChange(next);
const point = {
row: Math.min(active.row, next.rows.length - 1),
column: Math.min(active.column, next.columns.length - 1),
};
setActive(point);
selectedPointsRef.current = [point];
setSelectedCount(1);
window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point));
}, [active, history, onChange]);
const activateOnly = useCallback((point: Point) => {
setActive(point);
selectedPointsRef.current = [point];
setSelectedCount(1);
window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point));
}, []);
const moveRow = useCallback((from: number, to: number) => {
const next = moveExtraSheetRow(value, from, to);
if (next === value) return;
const point = { ...active, row: movedIndex(active.row, from, to) };
commitSheet(next);
activateOnly(point);
}, [active, activateOnly, commitSheet, value]);
const moveColumn = useCallback((from: number, to: number) => {
const next = moveExtraSheetColumn(value, from, to);
if (next === value) return;
const point = { ...active, column: movedIndex(active.column, from, to) };
commitSheet(next);
activateOnly(point);
}, [active, activateOnly, commitSheet, value]);
const handleDragEnd = useCallback(({ active: dragged, over }: DragEndEvent) => {
if (!over || dragged.id === over.id) return;
const draggedId = String(dragged.id);
const overId = String(over.id);
if (draggedId.startsWith("row:") && overId.startsWith("row:")) {
const from = value.rows.findIndex((row) => rowDragId(row.id) === draggedId);
const to = value.rows.findIndex((row) => rowDragId(row.id) === overId);
if (from !== -1 && to !== -1) moveRow(from, to);
}
if (draggedId.startsWith("column:") && overId.startsWith("column:")) {
const from = value.columns.findIndex((column) => columnDragId(column.id) === draggedId);
const to = value.columns.findIndex((column) => columnDragId(column.id) === overId);
if (from !== -1 && to !== -1) moveColumn(from, to);
}
}, [moveColumn, moveRow, value.columns, value.rows]);
const handleSheetKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("input, textarea, [contenteditable='true']")) return;
const key = event.key.toLowerCase();
const undo = (event.metaKey || event.ctrlKey) && !event.shiftKey && key === "z";
const redo = (event.metaKey || event.ctrlKey) && event.shiftKey && key === "z"
|| event.ctrlKey && !event.shiftKey && key === "y";
if (undo && canUndo) {
event.preventDefault();
travel(-1);
} else if (redo && canRedo) {
event.preventDefault();
travel(1);
}
};
const normalizedLabel = columnLabel.trim().toLocaleLowerCase(); const normalizedLabel = columnLabel.trim().toLocaleLowerCase();
const labelInvalid = !normalizedLabel || value.columns.some((column, index) => const labelInvalid = !normalizedLabel || value.columns.some((column, index) =>
index !== (renaming ? active.column : -1) && index !== (renaming ? active.column : -1) &&
@@ -151,7 +370,7 @@ export function ExtraSheetEditor({
const saveColumnLabel = () => { const saveColumnLabel = () => {
if (labelInvalid) return; if (labelInvalid) return;
if (renaming) { if (renaming) {
onChange({ commitSheet({
...value, ...value,
columns: value.columns.map((column, index) => columns: value.columns.map((column, index) =>
index === active.column ? { ...column, label: columnLabel.trim() } : column, index === active.column ? { ...column, label: columnLabel.trim() } : column,
@@ -159,12 +378,12 @@ export function ExtraSheetEditor({
}); });
} else { } else {
const column = value.columns.length; const column = value.columns.length;
onChange(addExtraSheetColumn( commitSheet(addExtraSheetColumn(
value, value,
columnLabel.trim(), columnLabel.trim(),
createId("extra-sheet-column"), createId("extra-sheet-column"),
)); ));
setActive((current) => ({ ...current, column })); activateOnly({ ...active, column });
} }
setLabelDialogOpen(false); setLabelDialogOpen(false);
}; };
@@ -175,27 +394,66 @@ export function ExtraSheetEditor({
setCellDraft(normalizeExtraRichText( setCellDraft(normalizeExtraRichText(
content ?? (column && row ? row.cells[column.id] ?? "" : ""), content ?? (column && row ? row.cells[column.id] ?? "" : ""),
)); ));
setOriginalCellDraft(normalizeExtraRichText(
content ?? (column && row ? row.cells[column.id] ?? "" : ""),
));
setStylePatch({});
setCellDialogOpen(true); setCellDialogOpen(true);
}, [value.columns, value.rows]); }, [value.columns, value.rows]);
const openActiveCellEditor = () => openCellEditorAt(active); const openActiveCellEditor = () => openCellEditorAt(active);
const columnIndicator = useCallback((props: ColumnIndicatorProps) => ( const columnIndicator = useCallback((props: ColumnIndicatorProps) => (
<EditableColumnIndicator {...props} onRename={openRenameColumn} /> <EditableColumnIndicator
), [openRenameColumn]); {...props}
id={columnDragId(value.columns[props.column].id)}
onRename={openRenameColumn}
/>
), [openRenameColumn, value.columns]);
const rowIndicator = useCallback((props: RowIndicatorProps) => (
<SortableRowIndicator {...props} id={rowDragId(value.rows[props.row].id)} />
), [value.rows]);
const dataEditor = useCallback((props: DataEditorProps<{ value: string }>) => ( const dataEditor = useCallback((props: DataEditorProps<{ value: string }>) => (
<DialogDataEditor {...props} onEdit={openCellEditorAt} /> <DialogDataEditor {...props} onEdit={openCellEditorAt} />
), [openCellEditorAt]); ), [openCellEditorAt]);
return ( return (
<div className="flex min-w-0 flex-col gap-3" aria-label="ตารางข้อมูลเสริม"> <div
className="flex min-w-0 flex-col gap-3"
aria-label="ตารางข้อมูลเสริม"
onKeyDown={handleSheetKeyDown}
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SimpleTooltip text={<>ย้อนกลับ <Kbd>Ctrl/⌘ Z</Kbd></>}>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={!canUndo}
aria-label="ย้อนกลับการแก้ไขตาราง"
onClick={() => travel(-1)}
>
<Undo2Icon />
</Button>
</SimpleTooltip>
<SimpleTooltip text={<>ทำซ้ำ <Kbd>Ctrl/⌘ Shift Z</Kbd></>}>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={!canRedo}
aria-label="ทำซ้ำการแก้ไขตาราง"
onClick={() => travel(1)}
>
<Redo2Icon />
</Button>
</SimpleTooltip>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const row = value.rows.length; const row = value.rows.length;
onChange(addExtraSheetRow(value, createId("extra-sheet-row"))); commitSheet(addExtraSheetRow(value, createId("extra-sheet-row")));
setActive((current) => ({ ...current, row })); activateOnly({ ...active, row });
}} }}
> >
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
@@ -220,7 +478,7 @@ export function ExtraSheetEditor({
className="absolute inset-0 cursor-pointer opacity-0" className="absolute inset-0 cursor-pointer opacity-0"
value={activeRow?.backgrounds?.[activeColumn?.id ?? ""] ?? DEFAULT_CELL_BACKGROUND} value={activeRow?.backgrounds?.[activeColumn?.id ?? ""] ?? DEFAULT_CELL_BACKGROUND}
onChange={(event) => { onChange={(event) => {
onChange(updateExtraSheetCells( commitSheet(updateExtraSheetCells(
value, value,
selectedPointsRef.current, selectedPointsRef.current,
{ backgroundColor: event.target.value }, { backgroundColor: event.target.value },
@@ -232,7 +490,7 @@ export function ExtraSheetEditor({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onChange(updateExtraSheetCells(value, selectedPointsRef.current, { backgroundColor: null }))} onClick={() => commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { backgroundColor: null }))}
> >
ล้างสีพื้นหลัง ล้างสีพื้นหลัง
</Button> </Button>
@@ -249,8 +507,7 @@ export function ExtraSheetEditor({
<DropdownMenuItem <DropdownMenuItem
disabled={!activeRow || active.row === 0} disabled={!activeRow || active.row === 0}
onClick={() => { onClick={() => {
onChange(moveExtraSheetRow(value, active.row, active.row - 1)); moveRow(active.row, active.row - 1);
setActive((current) => ({ ...current, row: current.row - 1 }));
}} }}
> >
<ArrowUpIcon />ย้ายขึ้น <ArrowUpIcon />ย้ายขึ้น
@@ -258,8 +515,7 @@ export function ExtraSheetEditor({
<DropdownMenuItem <DropdownMenuItem
disabled={!activeRow || active.row === value.rows.length - 1} disabled={!activeRow || active.row === value.rows.length - 1}
onClick={() => { onClick={() => {
onChange(moveExtraSheetRow(value, active.row, active.row + 1)); moveRow(active.row, active.row + 1);
setActive((current) => ({ ...current, row: current.row + 1 }));
}} }}
> >
<ArrowDownIcon />ย้ายลง <ArrowDownIcon />ย้ายลง
@@ -291,8 +547,7 @@ export function ExtraSheetEditor({
<DropdownMenuItem <DropdownMenuItem
disabled={!activeColumn || active.column === 0} disabled={!activeColumn || active.column === 0}
onClick={() => { onClick={() => {
onChange(moveExtraSheetColumn(value, active.column, active.column - 1)); moveColumn(active.column, active.column - 1);
setActive((current) => ({ ...current, column: current.column - 1 }));
}} }}
> >
<ArrowLeftIcon />ย้ายไปทางซ้าย <ArrowLeftIcon />ย้ายไปทางซ้าย
@@ -300,8 +555,7 @@ export function ExtraSheetEditor({
<DropdownMenuItem <DropdownMenuItem
disabled={!activeColumn || active.column === value.columns.length - 1} disabled={!activeColumn || active.column === value.columns.length - 1}
onClick={() => { onClick={() => {
onChange(moveExtraSheetColumn(value, active.column, active.column + 1)); moveColumn(active.column, active.column + 1);
setActive((current) => ({ ...current, column: current.column + 1 }));
}} }}
> >
<ArrowRightIcon />ย้ายไปทางขวา <ArrowRightIcon />ย้ายไปทางขวา
@@ -326,31 +580,41 @@ export function ExtraSheetEditor({
</div> </div>
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto rounded-lg border">
<Spreadsheet <DndContext
className="extra-sheet-grid" sensors={sensors}
data={matrix} collisionDetection={sameAxisCollision}
columnLabels={value.columns.map((column) => column.label)} onDragEnd={handleDragEnd}
ColumnIndicator={columnIndicator} >
DataViewer={LiteralDataViewer} <SortableContext items={sortableIds} strategy={rectSortingStrategy}>
DataEditor={dataEditor} <Spreadsheet
onActivate={(point) => { ref={spreadsheetRef}
setActive(point); className="extra-sheet-grid"
setSelectedCount(1); data={matrix}
selectedPointsRef.current = [point]; columnLabels={value.columns.map((column) => column.label)}
}} ColumnIndicator={columnIndicator}
onSelect={(nextSelection) => { RowIndicator={rowIndicator}
const range = nextSelection.toRange(matrix); DataViewer={LiteralDataViewer}
if (range) { DataEditor={dataEditor}
selectedPointsRef.current = Array.from(range); onActivate={(point) => {
setSelectedCount(range.size()); setActive(point);
} setSelectedCount(1);
}} selectedPointsRef.current = [point];
onChange={(next) => onChange(matrixToExtraSheet( }}
value, onSelect={(nextSelection) => {
next as ExtraSheetMatrix, const range = nextSelection.toRange(matrix);
createId, if (range) {
))} selectedPointsRef.current = Array.from(range);
/> setSelectedCount(range.size());
}
}}
onChange={(next) => commitSheet(matrixToExtraSheet(
value,
next as ExtraSheetMatrix,
createId,
))}
/>
</SortableContext>
</DndContext>
</div> </div>
<Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}> <Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}>
@@ -359,16 +623,30 @@ export function ExtraSheetEditor({
<DialogTitle>แก้ไขเซลล์</DialogTitle> <DialogTitle>แก้ไขเซลล์</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedCount > 1 {selectedCount > 1
? `นำข้อความเดียวกันไปใช้กับ ${selectedCount} เซลล์ที่เลือก` ? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์`
: `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`} : `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<RichTextEditor value={cellDraft} onChange={setCellDraft} showBubbleMenu={false} /> <RichTextEditor
value={cellDraft}
onChange={setCellDraft}
showBubbleMenu={false}
formatWholeContent={selectedCount > 1}
onFormatChange={(patch) => setStylePatch((current) => ({ ...current, ...patch }))}
/>
<DialogFooter showCloseButton> <DialogFooter showCloseButton>
<Button type="button" onClick={() => { <Button type="button" onClick={() => {
onChange(selectedCount > 1 const contentUnchanged = richTextContentSignature(cellDraft)
? updateExtraSheetCells(value, selectedPointsRef.current, { value: cellDraft }) === richTextContentSignature(originalCellDraft);
: updateExtraSheetCell(value, active.row, active.column, cellDraft)); if (selectedCount > 1 && contentUnchanged) {
if (Object.keys(stylePatch).length > 0) {
commitSheet(styleExtraSheetCells(value, selectedPointsRef.current, stylePatch));
}
} else {
commitSheet(selectedCount > 1
? updateExtraSheetCells(value, selectedPointsRef.current, { value: cellDraft })
: updateExtraSheetCell(value, active.row, active.column, cellDraft));
}
setCellDialogOpen(false); setCellDialogOpen(false);
}}>บันทึกเซลล์</Button> }}>บันทึกเซลล์</Button>
</DialogFooter> </DialogFooter>
@@ -412,11 +690,11 @@ export function ExtraSheetEditor({
showSuccessToast={false} showSuccessToast={false}
onConfirm={() => { onConfirm={() => {
if (deleteTarget === "column") { if (deleteTarget === "column") {
onChange(deleteExtraSheetColumn(value, active.column)); commitSheet(deleteExtraSheetColumn(value, active.column));
setActive((current) => ({ ...current, column: Math.max(0, current.column - 1) })); activateOnly({ ...active, column: Math.max(0, active.column - 1) });
} else if (deleteTarget === "row") { } else if (deleteTarget === "row") {
onChange(deleteExtraSheetRow(value, active.row)); commitSheet(deleteExtraSheetRow(value, active.row));
setActive((current) => ({ ...current, row: Math.max(0, current.row - 1) })); activateOnly({ ...active, row: Math.max(0, active.row - 1) });
} }
setDeleteTarget(null); setDeleteTarget(null);
}} }}
+50 -8
View File
@@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { import {
type RichTextStylePatch,
sanitizeRichTextHtml, sanitizeRichTextHtml,
sanitizeRichTextUrl, sanitizeRichTextUrl,
} from "@/lib/rich-text"; } from "@/lib/rich-text";
@@ -50,6 +51,8 @@ export interface RichTextEditorProps {
className?: string; className?: string;
invalid?: boolean; invalid?: boolean;
showBubbleMenu?: boolean; showBubbleMenu?: boolean;
formatWholeContent?: boolean;
onFormatChange?: (patch: RichTextStylePatch) => void;
} }
type Format = "bold" | "italic" | "underline" | "strike"; type Format = "bold" | "italic" | "underline" | "strike";
@@ -62,20 +65,39 @@ function normalizedEditorHtml(editor: Editor): string {
function FormatControls({ function FormatControls({
editor, editor,
onLink, onLink,
formatWholeContent,
onFormatChange,
}: { }: {
editor: Editor; editor: Editor;
onLink: () => void; onLink: () => void;
formatWholeContent: boolean;
onFormatChange?: (patch: RichTextStylePatch) => void;
}) { }) {
const formats: Format[] = ["bold", "italic", "underline", "strike"]; const formats: Format[] = ["bold", "italic", "underline", "strike"];
const active = formats.filter((format) => editor.isActive(format)); const active = formats.filter((format) => editor.isActive(format));
const color = editor.getAttributes("textStyle").color ?? "#FF3B30"; const color = editor.getAttributes("textStyle").color ?? "#FF3B30";
const toggleFormat = (format: Format) => { const toggleFormat = (format: Format) => {
if (formatWholeContent) editor.commands.selectAll();
const enabled = !editor.isActive(format);
const chain = editor.chain().focus(); const chain = editor.chain().focus();
if (format === "bold") chain.toggleBold().run(); if (format === "bold") {
if (format === "italic") chain.toggleItalic().run(); if (enabled) chain.setBold().run();
if (format === "underline") chain.toggleUnderline().run(); else chain.unsetBold().run();
if (format === "strike") chain.toggleStrike().run(); }
if (format === "italic") {
if (enabled) chain.setItalic().run();
else chain.unsetItalic().run();
}
if (format === "underline") {
if (enabled) chain.setUnderline().run();
else chain.unsetUnderline().run();
}
if (format === "strike") {
if (enabled) chain.setStrike().run();
else chain.unsetStrike().run();
}
onFormatChange?.({ [format]: enabled });
}; };
return ( return (
@@ -113,7 +135,11 @@ function FormatControls({
type="color" type="color"
value={color} value={color}
className="absolute inset-0 cursor-pointer opacity-0" className="absolute inset-0 cursor-pointer opacity-0"
onChange={(event) => editor.chain().focus().setColor(event.target.value).run()} onChange={(event) => {
if (formatWholeContent) editor.commands.selectAll();
editor.chain().focus().setColor(event.target.value).run();
onFormatChange?.({ color: event.target.value });
}}
/> />
</label> </label>
<Button <Button
@@ -122,7 +148,11 @@ function FormatControls({
size="icon-sm" size="icon-sm"
aria-label="ล้างสีข้อความ" aria-label="ล้างสีข้อความ"
title="ล้างสีข้อความ" title="ล้างสีข้อความ"
onClick={() => editor.chain().focus().unsetColor().run()} onClick={() => {
if (formatWholeContent) editor.commands.selectAll();
editor.chain().focus().unsetColor().run();
onFormatChange?.({ color: null });
}}
> >
<RemoveFormattingIcon /> <RemoveFormattingIcon />
</Button> </Button>
@@ -147,6 +177,8 @@ export function RichTextEditor({
className, className,
invalid = false, invalid = false,
showBubbleMenu = true, showBubbleMenu = true,
formatWholeContent = false,
onFormatChange,
}: RichTextEditorProps) { }: RichTextEditorProps) {
const [linkOpen, setLinkOpen] = useState(false); const [linkOpen, setLinkOpen] = useState(false);
const [linkText, setLinkText] = useState(""); const [linkText, setLinkText] = useState("");
@@ -241,11 +273,21 @@ export function RichTextEditor({
aria-invalid={invalid || undefined} aria-invalid={invalid || undefined}
> >
<div className="border-b bg-muted/50 p-1"> <div className="border-b bg-muted/50 p-1">
<FormatControls editor={editor} onLink={openLinkDialog} /> <FormatControls
editor={editor}
onLink={openLinkDialog}
formatWholeContent={formatWholeContent}
onFormatChange={onFormatChange}
/>
</div> </div>
{showBubbleMenu ? ( {showBubbleMenu ? (
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md"> <BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
<FormatControls editor={editor} onLink={openLinkDialog} /> <FormatControls
editor={editor}
onLink={openLinkDialog}
formatWholeContent={formatWholeContent}
onFormatChange={onFormatChange}
/>
</BubbleMenu> </BubbleMenu>
) : null} ) : null}
<EditorContent editor={editor} /> <EditorContent editor={editor} />
+18
View File
@@ -12,6 +12,7 @@ import {
moveExtraSheetColumn, moveExtraSheetColumn,
moveExtraSheetRow, moveExtraSheetRow,
normalizeExtraSheetRichText, normalizeExtraSheetRichText,
styleExtraSheetCells,
updateExtraSheetCell, updateExtraSheetCell,
updateExtraSheetCells, updateExtraSheetCells,
type ExtraSheet, type ExtraSheet,
@@ -124,6 +125,23 @@ describe("Extra sheet", () => {
.rows[0].backgrounds).toEqual({}); .rows[0].backgrounds).toEqual({});
}); });
it("styles selected cells without replacing their individual text", () => {
const sheet = createExtraSheet(idFactory());
const firstColumn = sheet.columns[0].id;
const secondColumn = sheet.columns[1].id;
sheet.rows[0].cells[firstColumn] = "<p>Alpha</p>";
sheet.rows[1].cells[secondColumn] = "<p><em>Beta</em></p>";
const styled = styleExtraSheetCells(sheet, [
{ row: 0, column: 0 },
{ row: 1, column: 1 },
], { bold: true });
expect(styled.rows[0].cells[firstColumn]).toBe("<p><strong>Alpha</strong></p>");
expect(styled.rows[1].cells[secondColumn]).toBe("<p><em><strong>Beta</strong></em></p>");
expect(styled.rows[0].cells[secondColumn]).toBe("");
});
it("renders legacy sheets that do not have background metadata yet", () => { it("renders legacy sheets that do not have background metadata yet", () => {
const sheet = createExtraSheet(idFactory()); const sheet = createExtraSheet(idFactory());
const legacy = { const legacy = {
+28 -1
View File
@@ -1,6 +1,10 @@
import * as z from "zod"; import * as z from "zod";
import { normalizeExtraRichText } from "@/lib/rich-text"; import {
applyRichTextStylePatch,
normalizeExtraRichText,
type RichTextStylePatch,
} from "@/lib/rich-text";
const extraSheetId = z.string().min(1).max(128); const extraSheetId = z.string().min(1).max(128);
@@ -213,6 +217,29 @@ export function updateExtraSheetCells(
}; };
} }
export function styleExtraSheetCells(
sheet: ExtraSheet,
points: Iterable<SheetPoint>,
patch: RichTextStylePatch,
): ExtraSheet {
const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`));
return {
...sheet,
rows: sheet.rows.map((row, rowIndex) => ({
...row,
cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => {
const columnIndex = sheet.columns.findIndex((column) => column.id === columnId);
return [
columnId,
selected.has(`${rowIndex}:${columnIndex}`)
? applyRichTextStylePatch(value, patch)
: value,
];
})),
})),
};
}
export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet { export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet {
return { return {
...sheet, ...sheet,
+34
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
applyRichTextStylePatch,
normalizeExtraRichText, normalizeExtraRichText,
richTextContentSignature,
sanitizeRichTextHtml, sanitizeRichTextHtml,
sanitizeRichTextUrl, sanitizeRichTextUrl,
} from "@/lib/rich-text"; } from "@/lib/rich-text";
@@ -46,3 +48,35 @@ describe("legacy Extra rich text conversion", () => {
])).toBe("<p><em>Ready</em></p>"); ])).toBe("<p><em>Ready</em></p>");
}); });
}); });
describe("bulk rich-text styles", () => {
it("applies a style to every text node without replacing structure or links", () => {
expect(applyRichTextStylePatch(
'<p>First <a href="https://example.com">link</a></p><ul><li>Second</li></ul>',
{ bold: true },
)).toBe(
'<p><strong>First </strong><a href="https://example.com" target="_blank" rel="noopener noreferrer"><strong>link</strong></a></p><ul><li><strong>Second</strong></li></ul>',
);
});
it("removes only requested styles and applies normalized colors", () => {
expect(applyRichTextStylePatch(
'<p><strong>Bold <em>and italic</em></strong></p>',
{ bold: false },
)).toBe("<p>Bold <em>and italic</em></p>");
expect(applyRichTextStylePatch("<p>Red</p>", { color: "#00ff00" }))
.toBe('<p><span style="color: #00FF00">Red</span></p>');
expect(applyRichTextStylePatch(
'<p><span style="color: #00FF00">Green</span></p>',
{ color: null },
)).toBe("<p>Green</p>");
});
it("compares content independently from visual formatting", () => {
expect(richTextContentSignature(
'<p><strong>Same</strong> <span style="color: #FF0000">text</span></p>',
)).toBe("<p>Same text</p>");
expect(richTextContentSignature("<p>Changed text</p>"))
.not.toBe(richTextContentSignature("<p>Original text</p>"));
});
});
+63
View File
@@ -24,6 +24,14 @@ const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i; const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
export interface RichTextStylePatch {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string | null;
}
function normalizeRichTextColor(value: string): string | null { function normalizeRichTextColor(value: string): string | null {
const color = value.trim(); const color = value.trim();
if (HEX_COLOR.test(color)) return color.toUpperCase(); if (HEX_COLOR.test(color)) return color.toUpperCase();
@@ -120,3 +128,58 @@ export function normalizeExtraRichText(
return content ? `<p>${content}</p>` : ""; return content ? `<p>${content}</p>` : "";
} }
const RICH_TEXT_MARK_TAGS = {
bold: "strong",
italic: "em",
underline: "u",
strike: "s",
} as const;
function removeRichTextTag(value: string, tag: string): string {
return value.replace(new RegExp(`</?${tag}(?:\\s[^>]*)?>`, "gi"), "");
}
function wrapRichTextNodes(value: string, tag: string, attributes = ""): string {
return value
.split(/(<[^>]+>)/g)
.map((part) => part && !part.startsWith("<")
? `<${tag}${attributes}>${part}</${tag}>`
: part)
.join("");
}
/** Returns rich-text structure with visual marks removed for content comparisons. */
export function richTextContentSignature(value: string): string {
let result = normalizeExtraRichText(value);
Object.values(RICH_TEXT_MARK_TAGS).forEach((tag) => {
result = removeRichTextTag(result, tag);
});
return removeRichTextTag(result, "span");
}
/** Applies uniform visual formatting without replacing a cell's own content. */
export function applyRichTextStylePatch(
value: string,
patch: RichTextStylePatch,
): string {
let result = normalizeExtraRichText(value);
(Object.entries(RICH_TEXT_MARK_TAGS) as Array<
[keyof Omit<RichTextStylePatch, "color">, string]
>).forEach(([format, tag]) => {
if (patch[format] === undefined) return;
result = removeRichTextTag(result, tag);
if (patch[format]) result = wrapRichTextNodes(result, tag);
});
if (patch.color !== undefined) {
result = removeRichTextTag(result, "span");
const color = patch.color ? normalizeRichTextColor(patch.color) : null;
if (color) {
result = wrapRichTextNodes(result, "span", ` style="color: ${color}"`);
}
}
return sanitizeRichTextHtml(result);
}