From 66238795bf5e5d8fb8799a1d5ed0e7e29a816eec Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 5 Sep 2026 19:30:50 +0700 Subject: [PATCH] feat : update excel --- components/admin/extra-sheet-editor.tsx | 394 ++++++++++++++++++++---- components/rich-text-editor.tsx | 58 +++- lib/guides/extra-sheet.test.ts | 18 ++ lib/guides/extra-sheet.ts | 29 +- lib/rich-text.test.ts | 34 ++ lib/rich-text.ts | 63 ++++ 6 files changed, 529 insertions(+), 67 deletions(-) diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx index 4acecc6..f5c1013 100644 --- a/components/admin/extra-sheet-editor.tsx +++ b/components/admin/extra-sheet-editor.tsx @@ -6,18 +6,48 @@ import { ArrowRightIcon, ArrowUpIcon, Columns3Icon, + GripHorizontalIcon, + GripVerticalIcon, PlusIcon, PencilIcon, PaletteIcon, + Redo2Icon, Rows3Icon, Trash2Icon, + Undo2Icon, } from "lucide-react"; -import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point } from "react-spreadsheet"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Spreadsheet, { + 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 { RichTextContent } from "@/components/rich-text-content"; import { RichTextEditor } from "@/components/rich-text-editor"; +import { SimpleTooltip } from "@/components/tooltip"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -38,6 +68,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; import { addExtraSheetColumn, addExtraSheetRow, @@ -48,15 +79,47 @@ import { moveExtraSheetColumn, moveExtraSheetRow, nextExtraSheetColumnLabel, + styleExtraSheetCells, updateExtraSheetCell, updateExtraSheetCells, type ExtraSheet, type ExtraSheetMatrix, } 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; +interface SheetHistory { + items: ExtraSheet[]; + index: number; + value: ExtraSheet; +} + const DEFAULT_CELL_BACKGROUND = "#16A34A"; +const SHEET_HISTORY_LIMIT = 80; +const columnDragId = (id: string) => `column:${id}`; +const rowDragId = (id: string) => `row:${id}`; + +const sameAxisCollision: CollisionDetection = (args) => { + const axis = String(args.active.id).split(":", 1)[0]; + return closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => String(container.id).startsWith(`${axis}:`), + ), + }); +}; + +function movedIndex(current: number, from: number, to: number): number { + if (current === from) return to; + if (from < to && current > from && current <= to) return current - 1; + if (to < from && current >= to && current < from) return current + 1; + return current; +} function LiteralDataViewer({ cell }: { cell?: { value?: unknown; backgroundColor?: string } }) { return ( @@ -86,18 +149,38 @@ function DialogDataEditor({ } function EditableColumnIndicator({ + id, column, label, selected, 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 ( - + + + + + ); +} + export function ExtraSheetEditor({ value, createId, @@ -117,6 +235,11 @@ export function ExtraSheetEditor({ onChange: (value: ExtraSheet) => void; onRemove: () => void; }) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const spreadsheetRef = useRef(null); const [active, setActive] = useState({ row: 0, column: 0 }); const [labelDialogOpen, setLabelDialogOpen] = useState(false); const [renaming, setRenaming] = useState(false); @@ -125,11 +248,107 @@ export function ExtraSheetEditor({ const [removeOpen, setRemoveOpen] = useState(false); const [cellDialogOpen, setCellDialogOpen] = useState(false); const [cellDraft, setCellDraft] = useState(""); + const [originalCellDraft, setOriginalCellDraft] = useState(""); + const [stylePatch, setStylePatch] = useState({}); const [selectedCount, setSelectedCount] = useState(1); + const [history, setHistory] = useState({ items: [value], index: 0, value }); const selectedPointsRef = useRef([active]); const matrix = useMemo(() => extraSheetToMatrix(value), [value]); const activeColumn = value.columns[active.column]; 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) => { + 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 labelInvalid = !normalizedLabel || value.columns.some((column, index) => index !== (renaming ? active.column : -1) && @@ -151,7 +370,7 @@ export function ExtraSheetEditor({ const saveColumnLabel = () => { if (labelInvalid) return; if (renaming) { - onChange({ + commitSheet({ ...value, columns: value.columns.map((column, index) => index === active.column ? { ...column, label: columnLabel.trim() } : column, @@ -159,12 +378,12 @@ export function ExtraSheetEditor({ }); } else { const column = value.columns.length; - onChange(addExtraSheetColumn( + commitSheet(addExtraSheetColumn( value, columnLabel.trim(), createId("extra-sheet-column"), )); - setActive((current) => ({ ...current, column })); + activateOnly({ ...active, column }); } setLabelDialogOpen(false); }; @@ -175,27 +394,66 @@ export function ExtraSheetEditor({ setCellDraft(normalizeExtraRichText( content ?? (column && row ? row.cells[column.id] ?? "" : ""), )); + setOriginalCellDraft(normalizeExtraRichText( + content ?? (column && row ? row.cells[column.id] ?? "" : ""), + )); + setStylePatch({}); setCellDialogOpen(true); }, [value.columns, value.rows]); const openActiveCellEditor = () => openCellEditorAt(active); const columnIndicator = useCallback((props: ColumnIndicatorProps) => ( - - ), [openRenameColumn]); + + ), [openRenameColumn, value.columns]); + const rowIndicator = useCallback((props: RowIndicatorProps) => ( + + ), [value.rows]); const dataEditor = useCallback((props: DataEditorProps<{ value: string }>) => ( ), [openCellEditorAt]); return ( -
+
+ ย้อนกลับ Ctrl/⌘ Z}> + + + ทำซ้ำ Ctrl/⌘ Shift Z}> + + @@ -249,8 +507,7 @@ export function ExtraSheetEditor({ { - onChange(moveExtraSheetRow(value, active.row, active.row - 1)); - setActive((current) => ({ ...current, row: current.row - 1 })); + moveRow(active.row, active.row - 1); }} > ย้ายขึ้น @@ -258,8 +515,7 @@ export function ExtraSheetEditor({ { - onChange(moveExtraSheetRow(value, active.row, active.row + 1)); - setActive((current) => ({ ...current, row: current.row + 1 })); + moveRow(active.row, active.row + 1); }} > ย้ายลง @@ -291,8 +547,7 @@ export function ExtraSheetEditor({ { - onChange(moveExtraSheetColumn(value, active.column, active.column - 1)); - setActive((current) => ({ ...current, column: current.column - 1 })); + moveColumn(active.column, active.column - 1); }} > ย้ายไปทางซ้าย @@ -300,8 +555,7 @@ export function ExtraSheetEditor({ { - onChange(moveExtraSheetColumn(value, active.column, active.column + 1)); - setActive((current) => ({ ...current, column: current.column + 1 })); + moveColumn(active.column, active.column + 1); }} > ย้ายไปทางขวา @@ -326,31 +580,41 @@ export function ExtraSheetEditor({
- column.label)} - ColumnIndicator={columnIndicator} - DataViewer={LiteralDataViewer} - DataEditor={dataEditor} - onActivate={(point) => { - setActive(point); - setSelectedCount(1); - selectedPointsRef.current = [point]; - }} - onSelect={(nextSelection) => { - const range = nextSelection.toRange(matrix); - if (range) { - selectedPointsRef.current = Array.from(range); - setSelectedCount(range.size()); - } - }} - onChange={(next) => onChange(matrixToExtraSheet( - value, - next as ExtraSheetMatrix, - createId, - ))} - /> + + + column.label)} + ColumnIndicator={columnIndicator} + RowIndicator={rowIndicator} + DataViewer={LiteralDataViewer} + DataEditor={dataEditor} + onActivate={(point) => { + setActive(point); + setSelectedCount(1); + selectedPointsRef.current = [point]; + }} + onSelect={(nextSelection) => { + const range = nextSelection.toRange(matrix); + if (range) { + selectedPointsRef.current = Array.from(range); + setSelectedCount(range.size()); + } + }} + onChange={(next) => commitSheet(matrixToExtraSheet( + value, + next as ExtraSheetMatrix, + createId, + ))} + /> + +
@@ -359,16 +623,30 @@ export function ExtraSheetEditor({ แก้ไขเซลล์ {selectedCount > 1 - ? `นำข้อความเดียวกันไปใช้กับ ${selectedCount} เซลล์ที่เลือก` + ? `การแก้ข้อความจะแทนที่ทั้ง ${selectedCount} เซลล์ แต่การเปลี่ยนรูปแบบจะเก็บข้อความเดิมของแต่ละเซลล์` : `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`} - + 1} + onFormatChange={(patch) => setStylePatch((current) => ({ ...current, ...patch }))} + /> @@ -412,11 +690,11 @@ export function ExtraSheetEditor({ showSuccessToast={false} onConfirm={() => { if (deleteTarget === "column") { - onChange(deleteExtraSheetColumn(value, active.column)); - setActive((current) => ({ ...current, column: Math.max(0, current.column - 1) })); + commitSheet(deleteExtraSheetColumn(value, active.column)); + activateOnly({ ...active, column: Math.max(0, active.column - 1) }); } else if (deleteTarget === "row") { - onChange(deleteExtraSheetRow(value, active.row)); - setActive((current) => ({ ...current, row: Math.max(0, current.row - 1) })); + commitSheet(deleteExtraSheetRow(value, active.row)); + activateOnly({ ...active, row: Math.max(0, active.row - 1) }); } setDeleteTarget(null); }} diff --git a/components/rich-text-editor.tsx b/components/rich-text-editor.tsx index e46dccc..7d1c8bb 100644 --- a/components/rich-text-editor.tsx +++ b/components/rich-text-editor.tsx @@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { + type RichTextStylePatch, sanitizeRichTextHtml, sanitizeRichTextUrl, } from "@/lib/rich-text"; @@ -50,6 +51,8 @@ export interface RichTextEditorProps { className?: string; invalid?: boolean; showBubbleMenu?: boolean; + formatWholeContent?: boolean; + onFormatChange?: (patch: RichTextStylePatch) => void; } type Format = "bold" | "italic" | "underline" | "strike"; @@ -62,20 +65,39 @@ function normalizedEditorHtml(editor: Editor): string { function FormatControls({ editor, onLink, + formatWholeContent, + onFormatChange, }: { editor: Editor; onLink: () => void; + formatWholeContent: boolean; + onFormatChange?: (patch: RichTextStylePatch) => void; }) { const formats: Format[] = ["bold", "italic", "underline", "strike"]; const active = formats.filter((format) => editor.isActive(format)); const color = editor.getAttributes("textStyle").color ?? "#FF3B30"; const toggleFormat = (format: Format) => { + if (formatWholeContent) editor.commands.selectAll(); + const enabled = !editor.isActive(format); const chain = editor.chain().focus(); - if (format === "bold") chain.toggleBold().run(); - if (format === "italic") chain.toggleItalic().run(); - if (format === "underline") chain.toggleUnderline().run(); - if (format === "strike") chain.toggleStrike().run(); + if (format === "bold") { + if (enabled) chain.setBold().run(); + else chain.unsetBold().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 ( @@ -113,7 +135,11 @@ function FormatControls({ type="color" value={color} 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 }); + }} /> @@ -147,6 +177,8 @@ export function RichTextEditor({ className, invalid = false, showBubbleMenu = true, + formatWholeContent = false, + onFormatChange, }: RichTextEditorProps) { const [linkOpen, setLinkOpen] = useState(false); const [linkText, setLinkText] = useState(""); @@ -241,11 +273,21 @@ export function RichTextEditor({ aria-invalid={invalid || undefined} >
- +
{showBubbleMenu ? ( - + ) : null} diff --git a/lib/guides/extra-sheet.test.ts b/lib/guides/extra-sheet.test.ts index 5988e73..6da45b6 100644 --- a/lib/guides/extra-sheet.test.ts +++ b/lib/guides/extra-sheet.test.ts @@ -12,6 +12,7 @@ import { moveExtraSheetColumn, moveExtraSheetRow, normalizeExtraSheetRichText, + styleExtraSheetCells, updateExtraSheetCell, updateExtraSheetCells, type ExtraSheet, @@ -124,6 +125,23 @@ describe("Extra sheet", () => { .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] = "

Alpha

"; + sheet.rows[1].cells[secondColumn] = "

Beta

"; + + const styled = styleExtraSheetCells(sheet, [ + { row: 0, column: 0 }, + { row: 1, column: 1 }, + ], { bold: true }); + + expect(styled.rows[0].cells[firstColumn]).toBe("

Alpha

"); + expect(styled.rows[1].cells[secondColumn]).toBe("

Beta

"); + expect(styled.rows[0].cells[secondColumn]).toBe(""); + }); + it("renders legacy sheets that do not have background metadata yet", () => { const sheet = createExtraSheet(idFactory()); const legacy = { diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts index 3426ece..371ffb8 100644 --- a/lib/guides/extra-sheet.ts +++ b/lib/guides/extra-sheet.ts @@ -1,6 +1,10 @@ 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); @@ -213,6 +217,29 @@ export function updateExtraSheetCells( }; } +export function styleExtraSheetCells( + sheet: ExtraSheet, + points: Iterable, + 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 { return { ...sheet, diff --git a/lib/rich-text.test.ts b/lib/rich-text.test.ts index 6a0a2a1..96ee608 100644 --- a/lib/rich-text.test.ts +++ b/lib/rich-text.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + applyRichTextStylePatch, normalizeExtraRichText, + richTextContentSignature, sanitizeRichTextHtml, sanitizeRichTextUrl, } from "@/lib/rich-text"; @@ -46,3 +48,35 @@ describe("legacy Extra rich text conversion", () => { ])).toBe("

Ready

"); }); }); + +describe("bulk rich-text styles", () => { + it("applies a style to every text node without replacing structure or links", () => { + expect(applyRichTextStylePatch( + '

First link

  • Second
', + { bold: true }, + )).toBe( + '

First link

  • Second
', + ); + }); + + it("removes only requested styles and applies normalized colors", () => { + expect(applyRichTextStylePatch( + '

Bold and italic

', + { bold: false }, + )).toBe("

Bold and italic

"); + expect(applyRichTextStylePatch("

Red

", { color: "#00ff00" })) + .toBe('

Red

'); + expect(applyRichTextStylePatch( + '

Green

', + { color: null }, + )).toBe("

Green

"); + }); + + it("compares content independently from visual formatting", () => { + expect(richTextContentSignature( + '

Same text

', + )).toBe("

Same text

"); + expect(richTextContentSignature("

Changed text

")) + .not.toBe(richTextContentSignature("

Original text

")); + }); +}); diff --git a/lib/rich-text.ts b/lib/rich-text.ts index 7281851..684b966 100644 --- a/lib/rich-text.ts +++ b/lib/rich-text.ts @@ -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 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 { const color = value.trim(); if (HEX_COLOR.test(color)) return color.toUpperCase(); @@ -120,3 +128,58 @@ export function normalizeExtraRichText( return content ? `

${content}

` : ""; } + +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(`]*)?>`, "gi"), ""); +} + +function wrapRichTextNodes(value: string, tag: string, attributes = ""): string { + return value + .split(/(<[^>]+>)/g) + .map((part) => part && !part.startsWith("<") + ? `<${tag}${attributes}>${part}` + : 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, 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); +}