diff --git a/app/globals.css b/app/globals.css index c48014b..10d6dea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -159,9 +159,68 @@ color: var(--accent-foreground); } -.extra-sheet-grid .Spreadsheet__data-viewer, -.extra-sheet-grid .Spreadsheet__data-viewer * { - color: #000 !important; +.extra-sheet-grid .Spreadsheet__table { + width: max-content; + min-width: 100%; +} + +.extra-sheet-grid .Spreadsheet__cell, +.extra-sheet-grid .Spreadsheet__header { + min-width: 0; +} + +.extra-sheet-grid .Spreadsheet__cell { + height: auto; + min-height: 2.25rem; + max-height: none; + vertical-align: top; +} + +.extra-sheet-grid .Spreadsheet__table > tbody > tr:first-child > th { + position: sticky; + top: 0; + z-index: 2; +} + +.extra-sheet-grid .Spreadsheet__table > tbody > tr > th:first-child { + position: sticky; + left: 0; + z-index: 1; +} + +.extra-sheet-grid .Spreadsheet__table > tbody > tr:first-child > th:first-child { + z-index: 3; +} + +.extra-sheet-row-number-column { + width: 6.5rem; +} + +.extra-sheet-column-resizer { + position: absolute; + inset-block: 0; + inset-inline-end: -0.25rem; + width: 0.5rem; + cursor: col-resize; + touch-action: none; +} + +.extra-sheet-grid .Spreadsheet__data-viewer { + min-height: 2.25rem; + white-space: pre-wrap; +} + +.extra-sheet-text { + --tw-prose-body: #000; + --tw-prose-headings: #000; + --tw-prose-lead: #000; + --tw-prose-links: #000; + --tw-prose-bold: #000; + --tw-prose-counters: #000; + --tw-prose-bullets: #000; + --tw-prose-quotes: #000; + --tw-prose-code: #000; + color: #000; } .extra-sheet-grid .Spreadsheet__data-editor textarea { diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx index a10b486..6184517 100644 --- a/components/admin/extra-sheet-editor.tsx +++ b/components/admin/extra-sheet-editor.tsx @@ -1,20 +1,35 @@ "use client"; import { + AlignCenterIcon, + AlignLeftIcon, + AlignRightIcon, + BoldIcon, + ClipboardIcon, + CopyIcon, + EraserIcon, GripHorizontalIcon, GripVerticalIcon, + ItalicIcon, PlusIcon, PaletteIcon, Redo2Icon, + ScissorsIcon, + StrikethroughIcon, Trash2Icon, Undo2Icon, + UnderlineIcon, } from "lucide-react"; import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, + EntireColumnsSelection, + EntireRowsSelection, type Point, type RowIndicatorProps, + type Selection, type SpreadsheetRef, + type TableProps, } from "react-spreadsheet"; import { useCallback, @@ -22,6 +37,7 @@ import { useMemo, useRef, useState, + type ClipboardEvent, type KeyboardEvent, } from "react"; import { @@ -48,6 +64,15 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation import { RichTextContent } from "@/components/rich-text-content"; import { SimpleTooltip } from "@/components/tooltip"; import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Dialog, DialogContent, @@ -58,22 +83,41 @@ import { } from "@/components/ui/dialog"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupTextarea, +} from "@/components/ui/input-group"; import { Kbd } from "@/components/ui/kbd"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { addExtraSheetColumn, addExtraSheetRow, + clearExtraSheetCellFormatting, + DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, + deleteExtraSheetColumn, + deleteExtraSheetRow, + extraSheetRangeToTsv, extraSheetToMatrix, + insertExtraSheetColumn, + insertExtraSheetRow, matrixToExtraSheet, moveExtraSheetColumn, moveExtraSheetRow, nextExtraSheetColumnLabel, + parseExtraSheetTsv, + pasteExtraSheetText, + resizeExtraSheetColumn, + styleExtraSheetCells, updateExtraSheetCells, + type ExtraSheetAlignment, type ExtraSheet, type ExtraSheetMatrix, } from "@/lib/guides/extra-sheet"; import { extraRichTextToPlainText, normalizeExtraRichText, + type RichTextStylePatch, } from "@/lib/rich-text"; import { cn } from "@/lib/utils"; @@ -105,22 +149,41 @@ function movedIndex(current: number, from: number, to: number): number { return current; } +function spreadsheetColumnName(index: number): string { + let value = index + 1; + let label = ""; + while (value > 0) { + value -= 1; + label = String.fromCharCode(65 + (value % 26)) + label; + value = Math.floor(value / 26); + } + return label; +} + function LiteralDataViewer({ cell, }: { - cell?: { value?: unknown; backgroundColor?: string }; + cell?: { + value?: unknown; + backgroundColor?: string; + alignment?: ExtraSheetAlignment; + }; }) { return (
); @@ -190,8 +253,16 @@ function EditableColumnIndicator({ column, label, selected, + onSelect, onRename, -}: ColumnIndicatorProps & { id: string; onRename: (column: number) => void }) { + onResize, + width, +}: ColumnIndicatorProps & { + id: string; + onRename: (column: number) => void; + onResize: (column: number, width: number, commit: boolean) => void; + width: number; +}) { const { attributes, listeners, @@ -206,6 +277,7 @@ function EditableColumnIndicator({ return ( onRename(column)} + onClick={(event) => onSelect(column, event.shiftKey)} + onDoubleClick={() => onRename(column)} > {label} + { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + onResize(column, width + (event.key === "ArrowRight" ? 8 : -8), true); + }} + onPointerDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = event.currentTarget.parentElement?.getBoundingClientRect().width + ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH; + const move = (moveEvent: PointerEvent) => + onResize(column, startWidth + moveEvent.clientX - startX, false); + const up = (upEvent: PointerEvent) => { + onResize(column, startWidth + upEvent.clientX - startX, true); + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up, { once: true }); + }} + /> ); } @@ -258,6 +359,7 @@ function SortableRowIndicator({ return ( ("cells"); + const [draftWidths, setDraftWidths] = useState>({}); const [history, setHistory] = useState({ items: [value], index: 0, value, }); const selectedPointsRef = useRef([active]); + const selectedRangeRef = useRef({ start: active, end: active }); const matrix = useMemo(() => extraSheetToMatrix(value), [value]); const activeColumn = value.columns[active.column]; const activeRow = value.rows[active.row]; + const activeValue = activeColumn && activeRow + ? activeRow.cells[activeColumn.id] ?? "" + : ""; + const activeAlignment = activeColumn && activeRow + ? activeRow.alignments?.[activeColumn.id] ?? "left" + : "left"; const canUndo = history.index > 0; const canRedo = history.index < history.items.length - 1; const sortableIds = useMemo( @@ -380,7 +491,9 @@ export function ExtraSheetEditor({ const activateOnly = useCallback((point: Point) => { setActive(point); selectedPointsRef.current = [point]; + selectedRangeRef.current = { start: point, end: point }; setSelectedCount(1); + setSelectionKind("cells"); window.requestAnimationFrame(() => spreadsheetRef.current?.activate(point)); }, []); @@ -446,8 +559,61 @@ export function ExtraSheetEditor({ } else if (redo && canRedo) { event.preventDefault(); travel(1); + } else if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { value: "" })); } }; + + const applyStyle = (patch: RichTextStylePatch) => + commitSheet(styleExtraSheetCells(value, selectedPointsRef.current, patch)); + const clearContents = () => + commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { value: "" })); + const clearFormatting = () => + commitSheet(clearExtraSheetCellFormatting(value, selectedPointsRef.current)); + const selectedTsv = () => extraSheetRangeToTsv( + value, + selectedRangeRef.current.start, + selectedRangeRef.current.end, + ); + const copySelection = async () => { + await navigator.clipboard.writeText(selectedTsv()); + }; + const cutSelection = async () => { + await copySelection(); + clearContents(); + }; + const pasteText = (text: string) => { + if (!text) return; + const pasted = parseExtraSheetTsv(text); + if (selectedCount > 1 && pasted.length === 1 && pasted[0].length === 1) { + commitSheet(updateExtraSheetCells(value, selectedPointsRef.current, { + value: normalizeExtraRichText(pasted[0][0]), + })); + return; + } + commitSheet(pasteExtraSheetText(value, active, text, createId)); + }; + const pasteFromClipboard = async () => { + pasteText(await navigator.clipboard.readText()); + }; + const handleCopy = (event: ClipboardEvent) => { + if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return; + event.preventDefault(); + event.stopPropagation(); + event.clipboardData.setData("text/plain", selectedTsv()); + }; + const handleCut = (event: ClipboardEvent) => { + if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return; + handleCopy(event); + clearContents(); + }; + const handlePaste = (event: ClipboardEvent) => { + if ((event.target as HTMLElement).closest("input, textarea, [contenteditable='true']")) return; + event.preventDefault(); + event.stopPropagation(); + pasteText(event.clipboardData.getData("text/plain")); + }; const normalizedLabel = columnLabel.trim().toLocaleLowerCase(); const labelInvalid = !normalizedLabel || @@ -496,15 +662,57 @@ export function ExtraSheetEditor({ } setLabelDialogOpen(false); }; + const insertRowAt = (index: number) => { + commitSheet(insertExtraSheetRow(value, index, createId("extra-sheet-row"))); + activateOnly({ ...active, row: index }); + }; + const insertColumnAt = (index: number) => { + commitSheet(insertExtraSheetColumn( + value, + index, + nextExtraSheetColumnLabel(value.columns), + createId("extra-sheet-column"), + )); + activateOnly({ ...active, column: index }); + }; + const removeActiveRow = () => { + if (value.rows.length === 1) return; + const next = deleteExtraSheetRow(value, active.row); + commitSheet(next); + activateOnly({ ...active, row: Math.min(active.row, next.rows.length - 1) }); + }; + const removeActiveColumn = () => { + if (value.columns.length === 1) return; + const next = deleteExtraSheetColumn(value, active.column); + commitSheet(next); + activateOnly({ ...active, column: Math.min(active.column, next.columns.length - 1) }); + }; + const resizeColumn = useCallback( + (column: number, width: number, commit: boolean) => { + setDraftWidths((current) => ({ ...current, [column]: width })); + if (!commit) return; + setDraftWidths((current) => { + const next = { ...current }; + delete next[column]; + return next; + }); + commitSheet(resizeExtraSheetColumn(value, column, width)); + }, + [commitSheet, value], + ); const columnIndicator = useCallback( (props: ColumnIndicatorProps) => ( ), - [openRenameColumn, value.columns], + [draftWidths, openRenameColumn, resizeColumn, value.columns], ); const rowIndicator = useCallback( (props: RowIndicatorProps) => ( @@ -528,14 +736,46 @@ export function ExtraSheetEditor({ ), [clearInitialEditText], ); + const sheetTable = useCallback( + ({ children, columns, hideColumnIndicators }: TableProps) => ( + + + {!hideColumnIndicators ? : null} + {Array.from({ length: columns }, (_, index) => ( + + ))} + + {children} +
+ ), + [draftWidths, value.columns], + ); + const activeFormats = [ + / Boolean(format)); + const activeTextColor = activeValue.match(/color:\s*(#[0-9A-Fa-f]{6})/i)?.[1] + ?? "#111111"; return (
-
+
@@ -554,6 +794,55 @@ export function ExtraSheetEditor({ + { + const patch: RichTextStylePatch = {}; + (["bold", "italic", "underline", "strike"] as const).forEach((format) => { + const wasActive = activeFormats.includes(format); + const isActive = formats.includes(format); + if (wasActive !== isActive) patch[format] = isActive; + }); + if (Object.keys(patch).length) applyStyle(patch); + }} + > + + + + + + + + + + + + + + @@ -603,7 +892,7 @@ export function ExtraSheetEditor({ nativeButton={false} > - สีพื้นหลัง {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"} + สีพื้นหลัง - + + + + + + +
-
- - - + + {selectedCount > 1 + ? `${selectedCount} cells` + : `${spreadsheetColumnName(active.column)}${active.row + 1}`} + + { + if (event.currentTarget.value !== extraRichTextToPlainText(activeValue)) { + commitSheet(updateExtraSheetCells(value, [active], { + value: normalizeExtraRichText(event.currentTarget.value), + })); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + /> + + + + + + + column.label)} ColumnIndicator={columnIndicator} RowIndicator={rowIndicator} + Table={sheetTable} DataViewer={LiteralDataViewer} DataEditor={dataEditor} onKeyDown={(event) => { @@ -690,12 +1016,23 @@ export function ExtraSheetEditor({ setActive(point); setSelectedCount(1); selectedPointsRef.current = [point]; + selectedRangeRef.current = { start: point, end: point }; + setSelectionKind("cells"); }} - onSelect={(nextSelection) => { + onSelect={(nextSelection: Selection) => { const range = nextSelection.toRange(matrix); if (range) { + setActive(range.start); selectedPointsRef.current = Array.from(range); + selectedRangeRef.current = { start: range.start, end: range.end }; setSelectedCount(range.size()); + setSelectionKind( + nextSelection instanceof EntireRowsSelection + ? "rows" + : nextSelection instanceof EntireColumnsSelection + ? "columns" + : "cells", + ); } }} onChange={(next) => @@ -703,10 +1040,48 @@ export function ExtraSheetEditor({ matrixToExtraSheet(value, next as ExtraSheetMatrix, createId), ) } - /> - - -
+ /> + + + + + + void cutSelection()}> + ตัด Ctrl/⌘ X + + void copySelection()}> + คัดลอก Ctrl/⌘ C + + void pasteFromClipboard()}> + วาง Ctrl/⌘ V + + + + {selectionKind === "rows" ? ( + + insertRowAt(active.row)}>เพิ่มแถวด้านบน + insertRowAt(active.row + 1)}>เพิ่มแถวด้านล่าง + + ลบแถว + + + ) : selectionKind === "columns" ? ( + + insertColumnAt(active.column)}>เพิ่มคอลัมน์ด้านซ้าย + insertColumnAt(active.column + 1)}>เพิ่มคอลัมน์ด้านขวา + openRenameColumn()}>เปลี่ยนชื่อคอลัมน์ + + ลบคอลัมน์ + + + ) : ( + + ล้างเนื้อหา + ล้างรูปแบบ + + )} + + diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index d5c8308..de46c54 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -56,6 +56,7 @@ import { } from "@/lib/guides/talents"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { normalizeExtraRichText } from "@/lib/rich-text"; +import { DEFAULT_EXTRA_SHEET_COLUMN_WIDTH } from "@/lib/guides/extra-sheet"; import { publicGuideSections } from "@/lib/guides/public-sections"; import { cn } from "@/lib/utils"; @@ -1363,8 +1364,21 @@ function ExtraSectionContent({ > total + (column.width ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH), + 0, + )}px`, + }} > + + {row.sheet.columns.map((column) => ( + + ))} + {row.sheet.columns.map((column) => ( @@ -1381,11 +1395,15 @@ function ExtraSectionContent({ - + ))} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000..b1cc4ef --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,270 @@ +"use client" + +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu" +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) { + return +} + +function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) { + return ( + + ) +} + +function ContextMenuTrigger({ + className, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + + ) +} + +function ContextMenuContent({ + className, + align = "start", + alignOffset = 4, + side = "right", + sideOffset = 0, + ...props +}: ContextMenuPrimitive.Popup.Props & + Pick< + ContextMenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) { + return ( + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: ContextMenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: ContextMenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: ContextMenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioGroup({ + ...props +}: ContextMenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: ContextMenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/lib/guides/extra-sheet.test.ts b/lib/guides/extra-sheet.test.ts index 6da45b6..c2e20e3 100644 --- a/lib/guides/extra-sheet.test.ts +++ b/lib/guides/extra-sheet.test.ts @@ -3,15 +3,20 @@ import { describe, expect, it } from "vitest"; import { addExtraSheetColumn, addExtraSheetRow, + clearExtraSheetCellFormatting, createExtraSheet, deleteExtraSheetColumn, deleteExtraSheetRow, extraSheetSchema, + extraSheetRangeToTsv, extraSheetToMatrix, matrixToExtraSheet, moveExtraSheetColumn, moveExtraSheetRow, normalizeExtraSheetRichText, + parseExtraSheetTsv, + pasteExtraSheetText, + resizeExtraSheetColumn, styleExtraSheetCells, updateExtraSheetCell, updateExtraSheetCells, @@ -152,6 +157,57 @@ describe("Extra sheet", () => { expect(extraSheetToMatrix(legacy)[0][0]).toEqual({ value: "", backgroundColor: undefined, + alignment: undefined, }); + + const parsed = extraSheetSchema.parse(legacy); + expect(parsed.columns[0].width).toBe(140); + expect(parsed.rows[0].alignments).toEqual({}); + }); + + it("persists bounded column widths and cell alignment", () => { + const sheet = createExtraSheet(idFactory()); + const resized = resizeExtraSheetColumn(sheet, 0, 999); + const aligned = updateExtraSheetCells(resized, [{ row: 0, column: 0 }], { + alignment: "center", + backgroundColor: "#123456", + }); + + expect(aligned.columns[0].width).toBe(480); + expect(extraSheetToMatrix(aligned)[0][0]).toMatchObject({ + alignment: "center", + backgroundColor: "#123456", + }); + expect(extraSheetSchema.safeParse(aligned).success).toBe(true); + }); + + it("clears visual formatting without clearing cell content", () => { + const sheet = createExtraSheet(idFactory()); + const columnId = sheet.columns[0].id; + sheet.rows[0].cells[columnId] = '

Alpha

'; + sheet.rows[0].backgrounds[columnId] = "#123456"; + sheet.rows[0].alignments[columnId] = "right"; + + const cleared = clearExtraSheetCellFormatting(sheet, [{ row: 0, column: 0 }]); + expect(cleared.rows[0].cells[columnId]).toBe("

Alpha

"); + expect(cleared.rows[0].backgrounds).toEqual({}); + expect(cleared.rows[0].alignments).toEqual({}); + }); + + it("copies plain TSV and pastes multiline cells while expanding the grid", () => { + const createId = idFactory(); + const sheet = createExtraSheet(createId); + const firstColumn = sheet.columns[0].id; + sheet.rows[0].cells[firstColumn] = "

Alpha
Beta

"; + const copied = extraSheetRangeToTsv(sheet, { row: 0, column: 0 }, { row: 0, column: 0 }); + + expect(copied).toBe('"Alpha\nBeta"'); + expect(parseExtraSheetTsv('"Alpha\nBeta"\tGamma')).toEqual([["Alpha\nBeta", "Gamma"]]); + + const pasted = pasteExtraSheetText(sheet, { row: 2, column: 2 }, '"Alpha\nBeta"\tGamma', createId); + expect(pasted.rows).toHaveLength(3); + expect(pasted.columns).toHaveLength(4); + expect(pasted.rows[2].cells[pasted.columns[2].id]).toBe("

Alpha
Beta

"); + expect(pasted.rows[2].cells[pasted.columns[3].id]).toBe("

Gamma

"); }); }); diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts index 371ffb8..630e2b9 100644 --- a/lib/guides/extra-sheet.ts +++ b/lib/guides/extra-sheet.ts @@ -2,21 +2,31 @@ import * as z from "zod"; import { applyRichTextStylePatch, + clearRichTextFormatting, + extraRichTextToPlainText, normalizeExtraRichText, type RichTextStylePatch, } from "@/lib/rich-text"; const extraSheetId = z.string().min(1).max(128); +export const DEFAULT_EXTRA_SHEET_COLUMN_WIDTH = 140; +export const MIN_EXTRA_SHEET_COLUMN_WIDTH = 96; +export const MAX_EXTRA_SHEET_COLUMN_WIDTH = 480; +export const extraSheetAlignmentSchema = z.enum(["left", "center", "right"]); +export type ExtraSheetAlignment = z.infer; export const extraSheetSchema = z.strictObject({ columns: z.array(z.strictObject({ id: extraSheetId, label: z.string().trim().min(1).max(120), + width: z.number().int().min(MIN_EXTRA_SHEET_COLUMN_WIDTH).max(MAX_EXTRA_SHEET_COLUMN_WIDTH) + .default(DEFAULT_EXTRA_SHEET_COLUMN_WIDTH), })).min(1), rows: z.array(z.strictObject({ id: extraSheetId, cells: z.record(z.string(), z.string().max(10_000)), backgrounds: z.record(z.string(), z.string().regex(/^#[0-9A-Fa-f]{6}$/)).default({}), + alignments: z.record(z.string(), extraSheetAlignmentSchema).default({}), })).min(1), }).superRefine((sheet, context) => { const columnIds = new Set(sheet.columns.map((column) => column.id)); @@ -32,7 +42,11 @@ export const extraSheetSchema = z.strictObject({ context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" }); } sheet.rows.forEach((row, rowIndex) => { - [...Object.keys(row.cells), ...Object.keys(row.backgrounds ?? {})].forEach((columnId) => { + [ + ...Object.keys(row.cells), + ...Object.keys(row.backgrounds ?? {}), + ...Object.keys(row.alignments ?? {}), + ].forEach((columnId) => { if (!columnIds.has(columnId)) { context.addIssue({ code: "custom", @@ -46,7 +60,11 @@ export const extraSheetSchema = z.strictObject({ export type ExtraSheet = z.output; -export type ExtraSheetCell = { value: string; backgroundColor?: string }; +export type ExtraSheetCell = { + value: string; + backgroundColor?: string; + alignment?: ExtraSheetAlignment; +}; export type ExtraSheetMatrix = Array>; type SheetPoint = { row: number; column: number }; @@ -61,6 +79,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe const columns = [1, 2].map((index) => ({ id: createId("extra-sheet-column"), label: `คอลัมน์ ${index}`, + width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, })); return { columns, @@ -68,6 +87,7 @@ export function createExtraSheet(createId: (prefix: string) => string): ExtraShe id: createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column) => [column.id, ""])), backgrounds: {}, + alignments: {}, })), }; } @@ -77,6 +97,7 @@ export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix { sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "", backgroundColor: row.backgrounds?.[column.id], + alignment: row.alignments?.[column.id], })), ); } @@ -95,6 +116,7 @@ export function matrixToExtraSheet( columns.push({ id: createId("extra-sheet-column"), label: nextExtraSheetColumnLabel(columns), + width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, }); } const rowCount = Math.max(sheet.rows.length, matrix.length); @@ -109,6 +131,11 @@ export function matrixToExtraSheet( ?? sheet.rows[rowIndex]?.backgrounds?.[column.id]; return backgroundColor ? [[column.id, backgroundColor]] : []; })), + alignments: Object.fromEntries(columns.flatMap((column, columnIndex) => { + const alignment = matrix[rowIndex]?.[columnIndex]?.alignment + ?? sheet.rows[rowIndex]?.alignments?.[column.id]; + return alignment ? [[column.id, alignment]] : []; + })), })); return { columns, rows }; } @@ -118,20 +145,65 @@ export function addExtraSheetColumn( label: string, id: string, ): ExtraSheet { + return insertExtraSheetColumn(sheet, sheet.columns.length, label, id); +} + +export function insertExtraSheetColumn( + sheet: ExtraSheet, + index: number, + label: string, + id: string, +): ExtraSheet { + const position = Math.max(0, Math.min(index, sheet.columns.length)); + const columns = [...sheet.columns]; + columns.splice(position, 0, { + id, + label, + width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, + }); return { - columns: [...sheet.columns, { id, label }], + columns, rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })), }; } export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet { + return insertExtraSheetRow(sheet, sheet.rows.length, id); +} + +export function insertExtraSheetRow( + sheet: ExtraSheet, + index: number, + id: string, +): ExtraSheet { + const position = Math.max(0, Math.min(index, sheet.rows.length)); + const rows = [...sheet.rows]; + rows.splice(position, 0, { + id, + cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])), + backgrounds: {}, + alignments: {}, + }); return { ...sheet, - rows: [...sheet.rows, { - id, - cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])), - backgrounds: {}, - }], + rows, + }; +} + +export function resizeExtraSheetColumn( + sheet: ExtraSheet, + index: number, + width: number, +): ExtraSheet { + if (!sheet.columns[index]) return sheet; + const normalized = Math.round(Math.max( + MIN_EXTRA_SHEET_COLUMN_WIDTH, + Math.min(MAX_EXTRA_SHEET_COLUMN_WIDTH, width), + )); + return { + ...sheet, + columns: sheet.columns.map((column, columnIndex) => + columnIndex === index ? { ...column, width: normalized } : column), }; } @@ -169,6 +241,7 @@ export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraS ...row, cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)), backgrounds: Object.fromEntries(Object.entries(row.backgrounds ?? {}).filter(([key]) => key !== columnId)), + alignments: Object.fromEntries(Object.entries(row.alignments ?? {}).filter(([key]) => key !== columnId)), })), }; } @@ -198,7 +271,11 @@ export function updateExtraSheetCell( export function updateExtraSheetCells( sheet: ExtraSheet, points: Iterable, - update: { value?: string; backgroundColor?: string | null }, + update: { + value?: string; + backgroundColor?: string | null; + alignment?: ExtraSheetAlignment | null; + }, ): ExtraSheet { const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); return { @@ -206,13 +283,121 @@ export function updateExtraSheetCells( rows: sheet.rows.map((row, rowIndex) => { const cells = { ...row.cells }; const backgrounds = { ...(row.backgrounds ?? {}) }; + const alignments = { ...(row.alignments ?? {}) }; sheet.columns.forEach((column, columnIndex) => { if (!selected.has(`${rowIndex}:${columnIndex}`)) return; if (update.value !== undefined) cells[column.id] = update.value; if (update.backgroundColor === null) delete backgrounds[column.id]; else if (update.backgroundColor !== undefined) backgrounds[column.id] = update.backgroundColor; + if (update.alignment === null) delete alignments[column.id]; + else if (update.alignment !== undefined) alignments[column.id] = update.alignment; }); - return { ...row, cells, backgrounds }; + return { ...row, cells, backgrounds, alignments }; + }), + }; +} + +export function clearExtraSheetCellFormatting( + sheet: ExtraSheet, + points: Iterable, +): ExtraSheet { + const selected = new Set(Array.from(points, ({ row, column }) => `${row}:${column}`)); + return { + ...sheet, + rows: sheet.rows.map((row, rowIndex) => { + const cells = { ...row.cells }; + const backgrounds = { ...(row.backgrounds ?? {}) }; + const alignments = { ...(row.alignments ?? {}) }; + sheet.columns.forEach((column, columnIndex) => { + if (!selected.has(`${rowIndex}:${columnIndex}`)) return; + cells[column.id] = clearRichTextFormatting(cells[column.id] ?? ""); + delete backgrounds[column.id]; + delete alignments[column.id]; + }); + return { ...row, cells, backgrounds, alignments }; + }), + }; +} + +function quoteTsvValue(value: string): string { + return /[\t\r\n"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; +} + +export function extraSheetRangeToTsv( + sheet: ExtraSheet, + start: SheetPoint, + end: SheetPoint, +): string { + const top = Math.min(start.row, end.row); + const bottom = Math.max(start.row, end.row); + const left = Math.min(start.column, end.column); + const right = Math.max(start.column, end.column); + return sheet.rows.slice(top, bottom + 1).map((row) => + sheet.columns.slice(left, right + 1).map((column) => + quoteTsvValue(extraRichTextToPlainText(row.cells[column.id] ?? "")), + ).join("\t"), + ).join("\n"); +} + +export function parseExtraSheetTsv(value: string): string[][] { + const rows: string[][] = [[]]; + let field = ""; + let quoted = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === '"') { + if (quoted && value[index + 1] === '"') { + field += '"'; + index += 1; + } else quoted = !quoted; + } else if (character === "\t" && !quoted) { + rows.at(-1)!.push(field); + field = ""; + } else if ((character === "\n" || character === "\r") && !quoted) { + if (character === "\r" && value[index + 1] === "\n") index += 1; + rows.at(-1)!.push(field); + rows.push([]); + field = ""; + } else field += character; + } + rows.at(-1)!.push(field); + return rows.length > 1 && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === "" + ? rows.slice(0, -1) + : rows; +} + +export function pasteExtraSheetText( + sheet: ExtraSheet, + start: SheetPoint, + text: string, + createId: (prefix: string) => string, +): ExtraSheet { + const pasted = parseExtraSheetTsv(text); + if (!pasted.length) return sheet; + let next = sheet; + const requiredRows = start.row + pasted.length; + const requiredColumns = start.column + Math.max(...pasted.map((row) => row.length)); + while (next.rows.length < requiredRows) { + next = addExtraSheetRow(next, createId("extra-sheet-row")); + } + while (next.columns.length < requiredColumns) { + next = addExtraSheetColumn( + next, + nextExtraSheetColumnLabel(next.columns), + createId("extra-sheet-column"), + ); + } + return { + ...next, + rows: next.rows.map((row, rowIndex) => { + const pastedRow = pasted[rowIndex - start.row]; + if (!pastedRow) return row; + const cells = { ...row.cells }; + pastedRow.forEach((cell, offset) => { + const column = next.columns[start.column + offset]; + if (column) cells[column.id] = normalizeExtraRichText(cell); + }); + return { ...row, cells }; }), }; } diff --git a/lib/rich-text.ts b/lib/rich-text.ts index da2cf9f..ff2c682 100644 --- a/lib/rich-text.ts +++ b/lib/rich-text.ts @@ -184,3 +184,14 @@ export function applyRichTextStylePatch( return sanitizeRichTextHtml(result); } + +/** Removes visual marks from rich text while preserving its structure and links. */ +export function clearRichTextFormatting(value: string): string { + return applyRichTextStylePatch(value, { + bold: false, + italic: false, + underline: false, + strike: false, + color: null, + }); +}