import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; import { Button } from "./ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { VisuallyHidden } from "./ui/visually-hidden"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface TableEditorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; initialData?: TableData | null; onConfirm: (markdown: string) => void; } type SortState = { col: number; dir: "asc" | "desc" } | null; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => { const [headers, setHeaders] = useState([]); const [rows, setRows] = useState([]); const [alignments, setAlignments] = useState([]); const [sortState, setSortState] = useState(null); const inputRefs = useRef>(new Map()); const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { if (el) inputRefs.current.set(key, el); else inputRefs.current.delete(key); }, []); useEffect(() => { if (open) { if (initialData) { setHeaders([...initialData.headers]); setRows(initialData.rows.map((r) => [...r])); setAlignments([...initialData.alignments]); } else { const empty = createEmptyTable(3, 2); setHeaders(empty.headers); setRows(empty.rows); setAlignments(empty.alignments); } setSortState(null); } }, [open, initialData]); const colCount = headers.length; const rowCount = rows.length; // ---- Cell editing ---- const updateHeader = (col: number, value: string) => { setHeaders((prev) => { const next = [...prev]; next[col] = value; return next; }); }; const updateCell = (row: number, col: number, value: string) => { setRows((prev) => { const next = prev.map((r) => [...r]); next[row][col] = value; return next; }); }; // ---- Add / Remove / Insert ---- const addColumn = () => { setHeaders((prev) => [...prev, ""]); setRows((prev) => prev.map((r) => [...r, ""])); setAlignments((prev) => [...prev, "none"]); setSortState(null); }; const insertColumnAt = (index: number) => { setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]); setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)])); setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]); setSortState(null); }; const removeColumn = (col: number) => { if (colCount <= 1) return; setHeaders((prev) => prev.filter((_, i) => i !== col)); setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col))); setAlignments((prev) => prev.filter((_, i) => i !== col)); setSortState(null); }; const addRow = () => { setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); }; const insertRowAt = (index: number) => { setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]); }; const removeRow = (row: number) => { if (rowCount <= 1) return; setRows((prev) => prev.filter((_, i) => i !== row)); }; // ---- Sorting ---- const sortByColumn = (col: number) => { let newDir: "asc" | "desc" = "asc"; if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; setSortState({ col, dir: newDir }); setRows((prev) => { const sorted = [...prev].sort((a, b) => { const va = (a[col] || "").toLowerCase(); const vb = (b[col] || "").toLowerCase(); const na = Number(va); const nb = Number(vb); if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na; const cmp = va.localeCompare(vb); return newDir === "asc" ? cmp : -cmp; }); return sorted; }); }; // ---- Tab / keyboard navigation ---- const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { if (e.key !== "Tab") return; e.preventDefault(); const nextCol = e.shiftKey ? col - 1 : col + 1; let nextRow = row; if (nextCol >= colCount) { if (row < rowCount - 1) { nextRow = row + 1; focusCell(nextRow, 0); } else { addRow(); setTimeout(() => focusCell(rowCount, 0), 0); } } else if (nextCol < 0) { if (row > 0) { nextRow = row - 1; focusCell(nextRow, colCount - 1); } else { focusCell(-1, colCount - 1); } } else { focusCell(nextRow, nextCol); } }; const focusCell = (row: number, col: number) => { inputRefs.current.get(`${row}:${col}`)?.focus(); }; const handleConfirm = () => { const md = serializeMarkdownTable({ headers, rows, alignments }); onConfirm(md); onOpenChange(false); }; const SortIndicator = ({ col }: { col: number }) => { if (sortState?.col === col) { return sortState.dir === "asc" ? : ; } return ; }; const totalColSpan = colCount + 2; return ( Table Editor Edit table headers, rows, columns and sort data
{/* Scrollable table area */}
{/* Wrapper: w-max + overflow-x-clip so row insert line is clipped (clip avoids breaking sticky); min-w-full so table fills when narrow */}
{/* ============ STICKY HEADER ============ */} {/* Mask row: solid background that hides content scrolling behind the header */} {/* Header row */} {/* Row-number spacer */} ))} {/* Add column at end */} {/* ============ DATA ROWS ============ */} {rows.map((row, rowIdx) => ( {/* Row number โ€” with insert-row zone on top border */} {/* Data cells */} {row.map((cell, col) => ( ))} {/* Row delete button */} ))}
{headers.map((header, col) => ( {/* ---- Insert-column zone (left edge of this column) ---- */}
insertColumnAt(col)} > {/* Blue vertical line through the entire table */}
{/* + button โ€” absolutely centered on the column border */} Insert column
{/* Header cell โ€” bg covers input + sort + delete */}
setInputRef(`-1:${col}`, el)} style={{ fontFamily: MONO_FONT }} className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" value={header} onChange={(e) => updateHeader(col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, -1, col)} placeholder={`Col ${col + 1}`} /> Sort column {colCount > 1 && ( Remove column )}
Add column
insertRowAt(rowIdx)} > {/* Blue horizontal line extending across the table */}
{/* + button at intersection of row border and first-column border */} Insert row
{rowIdx + 1}
setInputRef(`${rowIdx}:${col}`, el)} style={{ fontFamily: MONO_FONT }} className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" value={cell} onChange={(e) => updateCell(rowIdx, col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} /> {rowCount > 1 && ( Remove row )}
{/* Add row button below the table */}
{/* ============ FOOTER ============ */}
{colCount} {colCount === 1 ? "column" : "columns"} ยท {rowCount} {rowCount === 1 ? "row" : "rows"}
); }; export default TableEditorDialog;