mirror of https://github.com/usememos/memos.git
412 lines
18 KiB
TypeScript
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;
|