diff --git a/app/globals.css b/app/globals.css index b45c302..e8fed57 100644 --- a/app/globals.css +++ b/app/globals.css @@ -161,7 +161,6 @@ .extra-sheet-grid .Spreadsheet__table { width: max-content; - min-width: 100%; } .extra-sheet-grid .Spreadsheet__cell, @@ -210,6 +209,19 @@ white-space: pre-wrap; } +.extra-sheet-grid .Spreadsheet__active-cell--edit { + overflow: hidden; + border: 0; + box-shadow: none; +} + +.extra-sheet-grid .Spreadsheet__active-cell--edit:focus, +.extra-sheet-grid .Spreadsheet__active-cell--edit:focus-visible, +.extra-sheet-grid .Spreadsheet__data-editor textarea:focus, +.extra-sheet-grid .Spreadsheet__data-editor textarea:focus-visible { + outline: 0; +} + .extra-sheet-text { --tw-prose-body: #000; --tw-prose-headings: #000; @@ -224,23 +236,30 @@ } .extra-sheet-text--public { - --tw-prose-body: #fff; - --tw-prose-headings: #fff; - --tw-prose-lead: #fff; - --tw-prose-links: #fff; - --tw-prose-bold: #fff; - --tw-prose-counters: #fff; - --tw-prose-bullets: #fff; - --tw-prose-quotes: #fff; - --tw-prose-code: #fff; - color: #fff; + --tw-prose-body: currentColor; + --tw-prose-headings: currentColor; + --tw-prose-lead: currentColor; + --tw-prose-links: currentColor; + --tw-prose-bold: currentColor; + --tw-prose-counters: currentColor; + --tw-prose-bullets: currentColor; + --tw-prose-quotes: currentColor; + --tw-prose-code: currentColor; + color: inherit; } .extra-sheet-grid .Spreadsheet__data-editor textarea { box-sizing: border-box; - min-height: 100%; - min-width: 100%; - resize: both; + width: 100%; + height: 100%; + margin: 0; + border: 0; + outline: 0; + background: transparent; + color: #000; + font: inherit; + padding: 0.25rem; + resize: none; white-space: pre-wrap; } diff --git a/components/admin/extra-sheet-editor.tsx b/components/admin/extra-sheet-editor.tsx index 6184517..5ecb6d3 100644 --- a/components/admin/extra-sheet-editor.tsx +++ b/components/admin/extra-sheet-editor.tsx @@ -95,6 +95,8 @@ import { addExtraSheetRow, clearExtraSheetCellFormatting, DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, + MAX_EXTRA_SHEET_COLUMN_WIDTH, + MIN_EXTRA_SHEET_COLUMN_WIDTH, deleteExtraSheetColumn, deleteExtraSheetRow, extraSheetRangeToTsv, @@ -408,6 +410,7 @@ export function ExtraSheetEditor({ coordinateGetter: sortableKeyboardCoordinates, }), ); + const sheetRootRef = useRef(null); const spreadsheetRef = useRef(null); const initialEditTextRef = useRef(null); const [active, setActive] = useState({ row: 0, column: 0 }); @@ -417,7 +420,6 @@ export function ExtraSheetEditor({ const [removeOpen, setRemoveOpen] = useState(false); const [selectedCount, setSelectedCount] = useState(1); const [selectionKind, setSelectionKind] = useState<"cells" | "rows" | "columns">("cells"); - const [draftWidths, setDraftWidths] = useState>({}); const [history, setHistory] = useState({ items: [value], index: 0, @@ -548,17 +550,34 @@ export function ExtraSheetEditor({ const target = event.target as HTMLElement; if (target.closest("input, textarea, [contenteditable='true']")) return; const key = event.key.toLowerCase(); + const commandKey = event.metaKey || event.ctrlKey; const undo = - (event.metaKey || event.ctrlKey) && !event.shiftKey && key === "z"; + commandKey && !event.shiftKey && key === "z"; const redo = - ((event.metaKey || event.ctrlKey) && event.shiftKey && key === "z") || + (commandKey && event.shiftKey && key === "z") || (event.ctrlKey && !event.shiftKey && key === "y"); + const format = commandKey && !event.altKey && !event.shiftKey + ? ({ b: "bold", i: "italic", u: "underline" } as const)[key as "b" | "i" | "u"] + : event.altKey && event.shiftKey && key === "5" + ? "strike" + : undefined; if (undo && canUndo) { event.preventDefault(); travel(-1); } else if (redo && canRedo) { event.preventDefault(); travel(1); + } else if (format) { + event.preventDefault(); + const activePattern = { + bold: / { - setDraftWidths((current) => ({ ...current, [column]: width })); - if (!commit) return; - setDraftWidths((current) => { - const next = { ...current }; - delete next[column]; - return next; - }); - commitSheet(resizeExtraSheetColumn(value, column, width)); + const boundedWidth = Math.round(Math.max( + MIN_EXTRA_SHEET_COLUMN_WIDTH, + Math.min(MAX_EXTRA_SHEET_COLUMN_WIDTH, width), + )); + if (!commit) { + const columnElement = sheetRootRef.current?.querySelector( + `col[data-sheet-column-index="${column}"]`, + ); + if (columnElement) columnElement.style.width = `${boundedWidth}px`; + return; + } + commitSheet(resizeExtraSheetColumn(value, column, boundedWidth)); }, [commitSheet, value], ); @@ -707,12 +730,11 @@ export function ExtraSheetEditor({ id={columnDragId(value.columns[props.column].id)} onRename={openRenameColumn} onResize={resizeColumn} - width={draftWidths[props.column] - ?? value.columns[props.column].width + width={value.columns[props.column].width ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH} /> ), - [draftWidths, openRenameColumn, resizeColumn, value.columns], + [openRenameColumn, resizeColumn, value.columns], ); const rowIndicator = useCallback( (props: RowIndicatorProps) => ( @@ -744,9 +766,9 @@ export function ExtraSheetEditor({ {Array.from({ length: columns }, (_, index) => ( @@ -755,7 +777,7 @@ export function ExtraSheetEditor({ {children} ), - [draftWidths, value.columns], + [value.columns], ); const activeFormats = [ / - + - + - + - + diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 84803d8..0d21736 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -4829,7 +4829,7 @@ function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, arti } > - เพิ่มตาราง 2 × 2 + เพิ่มตาราง 4 × 6 )} diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 9eb8a68..64f0823 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -56,7 +56,10 @@ 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 { + DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, + extraSheetUsedRange, +} from "@/lib/guides/extra-sheet"; import { publicGuideSections } from "@/lib/guides/public-sections"; import { cn } from "@/lib/utils"; @@ -1295,12 +1298,14 @@ function ExtraSectionContent({ {action}
- {extra.rows.map((row) => ( + {extra.rows.map((row) => { + const displaySheet = row.sheet ? extraSheetUsedRange(row.sheet) : null; + return ( @@ -1355,7 +1360,7 @@ function ExtraSectionContent({ {row.percent ? {metric(row.percent, exporting ? "en-US" : undefined)} : null} - {row.sheet ? ( + {displaySheet ? ( total + (column.width ?? DEFAULT_EXTRA_SHEET_COLUMN_WIDTH), 0, )}px`, }} > - {row.sheet.columns.map((column) => ( + {displaySheet.columns.map((column) => ( - {row.sheet.columns.map((column) => ( + {displaySheet.columns.map((column) => ( {column.label} @@ -1389,9 +1394,9 @@ function ExtraSectionContent({ - {row.sheet.rows.map((sheetRow) => ( + {displaySheet.rows.map((sheetRow) => ( - {row.sheet!.columns.map((column) => ( + {displaySheet.columns.map((column) => ( ) : null} - ))} + ); + })}
); diff --git a/lib/guides/extra-sheet.test.ts b/lib/guides/extra-sheet.test.ts index c2e20e3..2ffa701 100644 --- a/lib/guides/extra-sheet.test.ts +++ b/lib/guides/extra-sheet.test.ts @@ -10,6 +10,7 @@ import { extraSheetSchema, extraSheetRangeToTsv, extraSheetToMatrix, + extraSheetUsedRange, matrixToExtraSheet, moveExtraSheetColumn, moveExtraSheetRow, @@ -29,14 +30,17 @@ function idFactory() { } describe("Extra sheet", () => { - it("creates a valid two-by-two sheet", () => { + it("creates a valid four-by-six sheet", () => { const sheet = createExtraSheet(idFactory()); expect(extraSheetSchema.safeParse(sheet).success).toBe(true); - expect(sheet.columns.map((column) => column.label)).toEqual(["คอลัมน์ 1", "คอลัมน์ 2"]); - expect(extraSheetToMatrix(sheet)).toEqual([ - [{ value: "", backgroundColor: undefined }, { value: "", backgroundColor: undefined }], - [{ value: "", backgroundColor: undefined }, { value: "", backgroundColor: undefined }], - ]); + expect(sheet.columns.map((column) => column.label)).toEqual( + Array.from({ length: 6 }, (_, index) => `คอลัมน์ ${index + 1}`), + ); + expect(extraSheetToMatrix(sheet)).toEqual( + Array.from({ length: 4 }, () => + Array.from({ length: 6 }, () => ({ value: "", backgroundColor: undefined })), + ), + ); }); it("preserves cell identity while rows and columns move", () => { @@ -49,10 +53,11 @@ describe("Extra sheet", () => { sheet.rows[1].cells[firstColumnId] = "C"; const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1); - expect(extraSheetToMatrix(moved)).toEqual([ - [{ value: "", backgroundColor: undefined }, { value: "C", backgroundColor: undefined }], - [{ value: "B", backgroundColor: undefined }, { value: "A", backgroundColor: undefined }], - ]); + const matrix = extraSheetToMatrix(moved); + expect(matrix).toHaveLength(4); + expect(matrix[0]).toHaveLength(6); + expect(matrix[0].slice(0, 2).map((cell) => cell?.value)).toEqual(["", "C"]); + expect(matrix[1].slice(0, 2).map((cell) => cell?.value)).toEqual(["B", "A"]); }); it("adds and removes rows and columns without orphaned cells", () => { @@ -63,14 +68,14 @@ describe("Extra sheet", () => { addExtraSheetColumn(initial, "Notes", columnId), createId("row"), ); - expect(expanded.rows).toHaveLength(3); + expect(expanded.rows).toHaveLength(5); expect(expanded.rows.every((row) => row.cells[columnId] === "")).toBe(true); - const reduced = deleteExtraSheetColumn(expanded, 2); + const reduced = deleteExtraSheetColumn(expanded, expanded.columns.length - 1); expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true); const secondRowId = reduced.rows[1].id; const withoutFirstRow = deleteExtraSheetRow(reduced, 0); - expect(withoutFirstRow.rows).toHaveLength(2); + expect(withoutFirstRow.rows).toHaveLength(4); expect(withoutFirstRow.rows[0].id).toBe(secondRowId); }); @@ -78,14 +83,16 @@ describe("Extra sheet", () => { const createId = idFactory(); const initial = createExtraSheet(createId); const expanded = matrixToExtraSheet(initial, [ - [{ value: "1" }, { value: "2" }, { value: "3" }], - [{ value: "4" }, { value: "5" }, { value: "6" }], - [{ value: "7" }, { value: "8" }, { value: "=1+1" }], + [], + [], + [], + [], + Array.from({ length: 9 }, (_, index) => ({ value: index === 8 ? "=1+1" : "" })), ], createId); - expect(expanded.columns).toHaveLength(3); - expect(expanded.rows).toHaveLength(3); - expect(extraSheetToMatrix(expanded)[2][2]?.value).toBe("=1+1"); + expect(expanded.columns).toHaveLength(9); + expect(expanded.rows).toHaveLength(5); + expect(extraSheetToMatrix(expanded)[4][8]?.value).toBe("=1+1"); expect(extraSheetSchema.safeParse(expanded).success).toBe(true); }); @@ -101,6 +108,23 @@ describe("Extra sheet", () => { } }); + it("returns only the used display range", () => { + const sheet = createExtraSheet(idFactory()); + expect(extraSheetUsedRange(sheet)).toBeNull(); + + sheet.rows[1].cells[sheet.columns[2].id] = "

Start

"; + sheet.rows[3].backgrounds[sheet.columns[4].id] = "#123456"; + const used = extraSheetUsedRange(sheet); + + expect(used?.columns.map((column) => column.label)).toEqual([ + "คอลัมน์ 3", + "คอลัมน์ 4", + "คอลัมน์ 5", + ]); + expect(used?.rows).toHaveLength(3); + expect(used && extraSheetSchema.safeParse(used).success).toBe(true); + }); + it("updates and sanitizes rich-text cells while preserving legacy text", () => { const initial = createExtraSheet(idFactory()); const updated = updateExtraSheetCell(initial, 0, 0, "legacy\ntext"); @@ -121,10 +145,11 @@ describe("Extra sheet", () => { { row: 1, column: 1 }, ], { value: "

Shared

", backgroundColor: "#16A34A" }); - expect(extraSheetToMatrix(updated)).toEqual([ - [{ value: "

Shared

", backgroundColor: "#16A34A" }, { value: "", backgroundColor: undefined }], - [{ value: "", backgroundColor: undefined }, { value: "

Shared

", backgroundColor: "#16A34A" }], - ]); + const matrix = extraSheetToMatrix(updated); + expect(matrix[0][0]).toMatchObject({ value: "

Shared

", backgroundColor: "#16A34A" }); + expect(matrix[1][1]).toMatchObject({ value: "

Shared

", backgroundColor: "#16A34A" }); + expect(matrix[0][1]).toMatchObject({ value: "", backgroundColor: undefined }); + expect(matrix[1][0]).toMatchObject({ value: "", backgroundColor: undefined }); expect(extraSheetSchema.safeParse(updated).success).toBe(true); expect(updateExtraSheetCells(updated, [{ row: 0, column: 0 }], { backgroundColor: null }) .rows[0].backgrounds).toEqual({}); @@ -204,10 +229,10 @@ describe("Extra sheet", () => { 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

"); + const pasted = pasteExtraSheetText(sheet, { row: 2, column: 7 }, '"Alpha\nBeta"\tGamma', createId); + expect(pasted.rows).toHaveLength(4); + expect(pasted.columns).toHaveLength(9); + expect(pasted.rows[2].cells[pasted.columns[7].id]).toBe("

Alpha
Beta

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

Gamma

"); }); }); diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts index 630e2b9..68253d0 100644 --- a/lib/guides/extra-sheet.ts +++ b/lib/guides/extra-sheet.ts @@ -68,6 +68,40 @@ export type ExtraSheetCell = { export type ExtraSheetMatrix = Array>; type SheetPoint = { row: number; column: number }; +export function extraSheetUsedRange(sheet: ExtraSheet): ExtraSheet | null { + const usedPoints = sheet.rows.flatMap((row, rowIndex) => + sheet.columns.flatMap((column, columnIndex) => { + const used = extraRichTextToPlainText(row.cells[column.id] ?? "").trim() + || row.backgrounds?.[column.id] + || row.alignments?.[column.id]; + return used ? [{ row: rowIndex, column: columnIndex }] : []; + }), + ); + if (!usedPoints.length) return null; + + const rowIndexes = usedPoints.map((point) => point.row); + const columnIndexes = usedPoints.map((point) => point.column); + const firstRow = Math.min(...rowIndexes); + const lastRow = Math.max(...rowIndexes); + const firstColumn = Math.min(...columnIndexes); + const lastColumn = Math.max(...columnIndexes); + + const columns = sheet.columns.slice(firstColumn, lastColumn + 1); + return { + columns, + rows: sheet.rows.slice(firstRow, lastRow + 1).map((row) => ({ + ...row, + cells: Object.fromEntries(columns.map((column) => [column.id, row.cells[column.id] ?? ""])), + backgrounds: Object.fromEntries(columns.flatMap((column) => + row.backgrounds?.[column.id] ? [[column.id, row.backgrounds[column.id]]] : [], + )), + alignments: Object.fromEntries(columns.flatMap((column) => + row.alignments?.[column.id] ? [[column.id, row.alignments[column.id]]] : [], + )), + })), + }; +} + export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string { const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase())); let index = columns.length + 1; @@ -76,14 +110,14 @@ export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): strin } export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet { - const columns = [1, 2].map((index) => ({ + const columns = Array.from({ length: 6 }, (_, index) => ({ id: createId("extra-sheet-column"), - label: `คอลัมน์ ${index}`, + label: `คอลัมน์ ${index + 1}`, width: DEFAULT_EXTRA_SHEET_COLUMN_WIDTH, })); return { columns, - rows: [1, 2].map(() => ({ + rows: Array.from({ length: 4 }, () => ({ id: createId("extra-sheet-row"), cells: Object.fromEntries(columns.map((column) => [column.id, ""])), backgrounds: {},