memos/web/src/components/TableEditorDialog.tsx

412 lines
18 KiB
TypeScript

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<string[]>([]);
const [rows, setRows] = useState<string[][]>([]);
const [alignments, setAlignments] = useState<ColumnAlignment[]>([]);
const [sortState, setSortState] = useState<SortState>(null);
const inputRefs = useRef<Map<string, HTMLInputElement>>(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<HTMLInputElement>, 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" ? <ArrowUpIcon className="size-3 text-primary" /> : <ArrowDownIcon className="size-3 text-primary" />;
}
return <ArrowUpDownIcon className="size-3 opacity-40" />;
};
const totalColSpan = colCount + 2;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="full" className="p-0! w-[min(56rem,calc(100vw-2rem))] h-[min(44rem,calc(100vh-4rem))]" showCloseButton={false}>
<VisuallyHidden>
<DialogClose />
</VisuallyHidden>
<VisuallyHidden>
<DialogTitle>Table Editor</DialogTitle>
</VisuallyHidden>
<VisuallyHidden>
<DialogDescription>Edit table headers, rows, columns and sort data</DialogDescription>
</VisuallyHidden>
<div className="flex flex-col h-full">
{/* Scrollable table area */}
<div className="flex-1 overflow-auto px-4 pb-2">
{/* Wrapper: w-max + overflow-x-clip so row insert line is clipped (clip avoids breaking sticky); min-w-full so table fills when narrow */}
<div className="relative min-w-full w-max overflow-x-clip">
<table className="w-full border-collapse text-sm">
{/* ============ STICKY HEADER ============ */}
<thead className="sticky top-0 z-20">
{/* Mask row: solid background that hides content scrolling behind the header */}
<tr>
<th colSpan={totalColSpan} className="h-4 bg-background p-0 border-0" />
</tr>
{/* Header row */}
<tr>
{/* Row-number spacer */}
<th className="w-7 min-w-7 bg-background" />
{headers.map((header, col) => (
<th key={col} className="p-0 min-w-[140px] relative bg-background">
{/* ---- Insert-column zone (left edge of this column) ---- */}
<div
className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 cursor-pointer"
onClick={() => insertColumnAt(col)}
>
{/* Blue vertical line through the entire table */}
<div
className="absolute left-1/2 -translate-x-1/2 top-0 w-0 group-hover/cins:w-[3px] bg-blue-500/70 transition-all pointer-events-none"
style={{ bottom: "-200rem" }}
/>
{/* + button — absolutely centered on the column border */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/cins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
>
<PlusIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Insert column</TooltipContent>
</Tooltip>
</div>
{/* Header cell — bg covers input + sort + delete */}
<div className="flex items-center bg-accent/50 border border-border">
<input
ref={(el) => 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}`}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors"
onClick={() => sortByColumn(col)}
>
<SortIndicator col={col} />
</button>
</TooltipTrigger>
<TooltipContent>Sort column</TooltipContent>
</Tooltip>
{colCount > 1 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center size-7 mr-2 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
onClick={() => removeColumn(col)}
>
<TrashIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove column</TooltipContent>
</Tooltip>
)}
</div>
</th>
))}
{/* Add column at end */}
<th className="w-8 min-w-8 align-middle bg-background">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center size-7 rounded cursor-pointer hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={addColumn}
>
<PlusIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Add column</TooltipContent>
</Tooltip>
</th>
</tr>
</thead>
{/* ============ DATA ROWS ============ */}
<tbody>
{rows.map((row, rowIdx) => (
<React.Fragment key={rowIdx}>
<tr>
{/* Row number — with insert-row zone on top border */}
<td className="w-7 min-w-7 text-center align-middle relative">
<div
className="group/rins absolute -top-[10px] -left-1 right-0 h-5 z-10 cursor-pointer"
onClick={() => insertRowAt(rowIdx)}
>
{/* Blue horizontal line extending across the table */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-0 group-hover/rins:h-[3px] bg-blue-500/70 transition-all pointer-events-none"
style={{ width: "200rem" }}
/>
{/* + button at intersection of row border and first-column border */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground cursor-pointer opacity-0 group-hover/rins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
>
<PlusIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Insert row</TooltipContent>
</Tooltip>
</div>
<span className="text-xs text-muted-foreground">{rowIdx + 1}</span>
</td>
{/* Data cells */}
{row.map((cell, col) => (
<td key={col} className="p-0">
<input
ref={(el) => 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)}
/>
</td>
))}
{/* Row delete button */}
<td className="w-8 min-w-8 align-middle">
{rowCount > 1 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center size-7 rounded cursor-pointer opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
onClick={() => removeRow(rowIdx)}
>
<TrashIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove row</TooltipContent>
</Tooltip>
)}
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Add row button below the table */}
<div className="flex justify-center mt-2">
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
<PlusIcon className="size-3.5" />
Add row
</Button>
</div>
</div>
{/* ============ FOOTER ============ */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"}
</span>
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addRow}>
<PlusIcon className="size-3.5" />
Add row
</Button>
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground cursor-pointer" onClick={addColumn}>
<PlusIcon className="size-3.5" />
Add column
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button className="cursor-pointer" onClick={handleConfirm}>
Confirm
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default TableEditorDialog;