mirror of https://github.com/usememos/memos.git
feat: polish table editor insert zones, sticky header, and layout
Insert column/row zones: - Expanded hover area to 32px wide (columns) and 20px tall (rows) so the insert trigger is much easier to reach - Column + button is centered right above the vertical border between two columns, inside the header cells with absolute positioning - Row + button is centered on the horizontal border between two rows, rendered via a zero-height spacer <tr> with an absolutely positioned hover zone - z-index set to z-30 (above the z-20 sticky header) so column insert buttons render on top of everything - On hover, a 3px blue highlight line appears at the exact border where the new column/row will be inserted, giving clear visual feedback - The entire zone is clickable (not just the button) for easier use Sticky header improvements: - Added a solid bg-background mask row at the top of the sticky thead that hides table cells scrolling underneath the header area - All header cells including the row-number spacer and add-column button now have explicit bg-background so nothing bleeds through - Header cell background (bg-accent/50) now wraps the full cell content including sort and delete buttons (moved bg from input to the containing div), giving the header a cohesive look Row insert zones use dedicated spacer <tr> elements between data rows (instead of absolutely positioned elements inside cells), which is more reliable across different table widths and avoids clipping issues. Co-authored-by: milvasic <milvasic@users.noreply.github.com>
This commit is contained in:
parent
8c35c75dec
commit
5b8a400c78
|
|
@ -1,5 +1,5 @@
|
|||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnAlignment, TableData } from "@/utils/markdown-table";
|
||||
import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table";
|
||||
|
|
@ -40,11 +40,8 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
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);
|
||||
}
|
||||
if (el) inputRefs.current.set(key, el);
|
||||
else inputRefs.current.delete(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -129,16 +126,13 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
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;
|
||||
}
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na;
|
||||
const cmp = va.localeCompare(vb);
|
||||
return newDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
|
@ -149,43 +143,38 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
// ---- Tab / keyboard navigation ----
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, row: number, col: number) => {
|
||||
if (e.key === "Tab") {
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
focusCell(nextRow, nextCol);
|
||||
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) => {
|
||||
const key = `${row}:${col}`;
|
||||
const el = inputRefs.current.get(key);
|
||||
el?.focus();
|
||||
inputRefs.current.get(`${row}:${col}`)?.focus();
|
||||
};
|
||||
|
||||
// ---- Confirm ----
|
||||
|
||||
const handleConfirm = () => {
|
||||
const data: TableData = { headers, rows, alignments };
|
||||
const md = serializeMarkdownTable(data);
|
||||
const md = serializeMarkdownTable({ headers, rows, alignments });
|
||||
onConfirm(md);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
@ -199,6 +188,9 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
return <ArrowUpDownIcon className="size-3 opacity-40" />;
|
||||
};
|
||||
|
||||
// Total colSpan: row-number col + data cols + action col
|
||||
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}>
|
||||
|
|
@ -211,52 +203,54 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
<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 p-4 pb-2">
|
||||
{/* Insert-column buttons row (above the table) */}
|
||||
<div className="relative w-full" style={{ height: 0 }}>
|
||||
{/* We position "+" buttons at each column border using the same grid layout */}
|
||||
<div className="flex items-start">
|
||||
{/* Offset for row-number column */}
|
||||
<div className="w-7 min-w-7 shrink-0" />
|
||||
{headers.map((_, col) => (
|
||||
<div key={col} className="relative min-w-[140px] flex-1">
|
||||
{/* "+" button on the left edge of each column (= between col-1 and col) */}
|
||||
{col > 0 && (
|
||||
<div className="absolute -left-2.5 -top-1 z-10 flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground opacity-0 hover:opacity-100 focus:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm [div:hover>&]:opacity-70"
|
||||
onClick={() => insertColumnAt(col)}
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Insert column</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-4 pb-2">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* Sticky header */}
|
||||
<thead className="sticky top-0 z-20 bg-background">
|
||||
{/* ============ STICKY HEADER ============ */}
|
||||
<thead className="sticky top-0 z-20">
|
||||
{/* Mask row: solid background strip that hides content scrolling behind the header */}
|
||||
<tr>
|
||||
{/* Row number column */}
|
||||
<th className="w-7 min-w-7" />
|
||||
<th colSpan={totalColSpan} className="h-4 bg-background p-0 border-0" />
|
||||
</tr>
|
||||
|
||||
{/* Actual 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]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<th key={col} className="p-0 min-w-[140px] relative bg-background">
|
||||
{/* ---- Insert-column zone (between col-1 and col) ---- */}
|
||||
{col > 0 && (
|
||||
<div
|
||||
className="group/cins absolute -left-4 top-0 bottom-0 w-8 z-30 flex items-center justify-center cursor-pointer"
|
||||
onClick={() => insertColumnAt(col)}
|
||||
>
|
||||
{/* Blue vertical highlight line */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0 group-hover/cins:w-[3px] bg-blue-500/70 transition-all rounded-full" />
|
||||
{/* + button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground 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 content — bg extends across input + action buttons */}
|
||||
<div className="flex items-center gap-0.5 bg-accent/50 border border-border rounded-tl-md">
|
||||
<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-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
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)}
|
||||
|
|
@ -291,8 +285,9 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
</div>
|
||||
</th>
|
||||
))}
|
||||
|
||||
{/* Add column at end */}
|
||||
<th className="w-8 min-w-8 align-middle">
|
||||
<th className="w-8 min-w-8 align-middle bg-background">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -309,38 +304,26 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Data rows */}
|
||||
{/* ============ DATA ROWS ============ */}
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="group/row relative">
|
||||
{/* Row number */}
|
||||
<td className="w-7 min-w-7 text-center align-middle">
|
||||
<span className="text-xs text-muted-foreground">{rowIdx + 1}</span>
|
||||
</td>
|
||||
|
||||
{/* Data cells */}
|
||||
{row.map((cell, col) => (
|
||||
<td key={col} className="p-0 relative">
|
||||
<input
|
||||
ref={(el) => setInputRef(`${rowIdx}:${col}`, el)}
|
||||
style={{ fontFamily: MONO_FONT }}
|
||||
className={cn(
|
||||
"w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||
rowIdx === rowCount - 1 && "rounded-bl-md",
|
||||
)}
|
||||
value={cell}
|
||||
onChange={(e) => updateCell(rowIdx, col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, rowIdx, col)}
|
||||
/>
|
||||
{/* Insert-row button: shown on the top border between rows */}
|
||||
{rowIdx > 0 && col === 0 && (
|
||||
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2 z-10">
|
||||
<React.Fragment key={rowIdx}>
|
||||
{/* ---- Insert-row zone (between row rowIdx-1 and rowIdx) ---- */}
|
||||
{rowIdx > 0 && (
|
||||
<tr className="h-0">
|
||||
<td colSpan={totalColSpan} className="p-0 h-0 relative border-0">
|
||||
<div
|
||||
className="group/rins absolute -top-[10px] left-0 right-0 h-5 z-10 flex items-center justify-center cursor-pointer"
|
||||
onClick={() => insertRowAt(rowIdx)}
|
||||
>
|
||||
{/* Blue horizontal highlight line */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-7 right-8 h-0 group-hover/rins:h-[3px] bg-blue-500/70 transition-all rounded-full" />
|
||||
{/* + button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground opacity-0 hover:opacity-100 focus:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm [tr:hover>&]:opacity-70"
|
||||
onClick={() => insertRowAt(rowIdx)}
|
||||
className="relative z-10 flex items-center justify-center size-5 rounded-full bg-background border border-border text-muted-foreground opacity-0 group-hover/rins:opacity-100 hover:text-primary hover:border-primary transition-all shadow-sm"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
</button>
|
||||
|
|
@ -348,34 +331,59 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
<TooltipContent>Insert row</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* ---- Actual data row ---- */}
|
||||
<tr>
|
||||
{/* Row number */}
|
||||
<td className="w-7 min-w-7 text-center align-middle">
|
||||
<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={cn(
|
||||
"w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||
rowIdx === rowCount - 1 && "rounded-bl-md",
|
||||
)}
|
||||
value={cell}
|
||||
onChange={(e) => updateCell(rowIdx, col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, rowIdx, col)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* Row delete button (end of row) */}
|
||||
<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 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>
|
||||
))}
|
||||
|
||||
{/* Row delete button (end of row) */}
|
||||
<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 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>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* ============ 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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue