feat : update excel
This commit is contained in:
@@ -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,11 +580,19 @@ export function ExtraSheetEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={sameAxisCollision}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
||||||
<Spreadsheet
|
<Spreadsheet
|
||||||
|
ref={spreadsheetRef}
|
||||||
className="extra-sheet-grid"
|
className="extra-sheet-grid"
|
||||||
data={matrix}
|
data={matrix}
|
||||||
columnLabels={value.columns.map((column) => column.label)}
|
columnLabels={value.columns.map((column) => column.label)}
|
||||||
ColumnIndicator={columnIndicator}
|
ColumnIndicator={columnIndicator}
|
||||||
|
RowIndicator={rowIndicator}
|
||||||
DataViewer={LiteralDataViewer}
|
DataViewer={LiteralDataViewer}
|
||||||
DataEditor={dataEditor}
|
DataEditor={dataEditor}
|
||||||
onActivate={(point) => {
|
onActivate={(point) => {
|
||||||
@@ -345,12 +607,14 @@ export function ExtraSheetEditor({
|
|||||||
setSelectedCount(range.size());
|
setSelectedCount(range.size());
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(next) => onChange(matrixToExtraSheet(
|
onChange={(next) => commitSheet(matrixToExtraSheet(
|
||||||
value,
|
value,
|
||||||
next as ExtraSheetMatrix,
|
next as ExtraSheetMatrix,
|
||||||
createId,
|
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)
|
||||||
|
=== richTextContentSignature(originalCellDraft);
|
||||||
|
if (selectedCount > 1 && contentUnchanged) {
|
||||||
|
if (Object.keys(stylePatch).length > 0) {
|
||||||
|
commitSheet(styleExtraSheetCells(value, selectedPointsRef.current, stylePatch));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
commitSheet(selectedCount > 1
|
||||||
? updateExtraSheetCells(value, selectedPointsRef.current, { value: cellDraft })
|
? updateExtraSheetCells(value, selectedPointsRef.current, { value: cellDraft })
|
||||||
: updateExtraSheetCell(value, active.row, active.column, 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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user