feat: table editor dialog for visual markdown table editing

Add a dialog-based table editor to create and edit markdown tables via a
visual grid instead of raw pipe-delimited text.

Features:
- Visual grid with inputs for headers and cells; add/remove rows and columns
- Sort columns (asc/desc, text and numeric); tab navigation (new row at end)
- Insert column/row between columns/rows via hover zones and + buttons with
  blue highlight lines clipped to table bounds
- Sticky header with solid background; square headers; monospace cell font
- Row numbers with insert zones; delete row at row end; delete column with
  spacing from insert button; Add row/Add column in footer and below table
- Delete table button on rendered tables (with confirm); edit pencil opens
  dialog with parsed data; always-visible sort/delete at 40% opacity
- Fixed-size dialog (56rem x 44rem); /table slash command and Table in
  InsertMenu open dialog; Command.action support for dialog-based commands

New: TableEditorDialog.tsx, utils/markdown-table.ts. Integration in
SlashCommands, EditorContent, InsertMenu, MemoContent Table.

Made-with: Cursor
This commit is contained in:
Milan Vasić 2026-03-03 23:04:46 +01:00 committed by milvasic
parent 2327f4e3a6
commit b4c2c180b8
11 changed files with 916 additions and 26 deletions

View File

@ -1,20 +1,157 @@
import { PencilIcon, TrashIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import TableEditorDialog from "@/components/TableEditorDialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
import type { TableData } from "@/utils/markdown-table";
import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table";
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
import type { ReactMarkdownProps } from "./markdown/types";
// ---------------------------------------------------------------------------
// Table (root wrapper with edit + delete buttons)
// ---------------------------------------------------------------------------
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
const tableRef = useRef<HTMLDivElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableData, setTableData] = useState<TableData | null>(null);
const [tableIndex, setTableIndex] = useState(-1);
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const { mutate: updateMemo } = useUpdateMemo();
/** Resolve which markdown table index this rendered table corresponds to. */
const resolveTableIndex = useCallback(() => {
const container = tableRef.current?.closest('[class*="wrap-break-word"]');
if (!container) return -1;
const allTables = container.querySelectorAll("table");
for (let i = 0; i < allTables.length; i++) {
if (tableRef.current?.contains(allTables[i])) return i;
}
return -1;
}, []);
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const idx = resolveTableIndex();
const tables = findAllTables(memo.content);
if (idx < 0 || idx >= tables.length) return;
const parsed = parseMarkdownTable(tables[idx].text);
if (!parsed) return;
setTableData(parsed);
setTableIndex(idx);
setDialogOpen(true);
},
[memo.content, resolveTableIndex],
);
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const idx = resolveTableIndex();
if (idx < 0) return;
setTableIndex(idx);
setDeleteDialogOpen(true);
},
[resolveTableIndex],
);
const handleConfirmEdit = useCallback(
(markdown: string) => {
if (tableIndex < 0) return;
const newContent = replaceNthTable(memo.content, tableIndex, markdown);
updateMemo({
update: { name: memo.name, content: newContent },
updateMask: ["content"],
});
},
[memo.content, memo.name, tableIndex, updateMemo],
);
const handleConfirmDelete = useCallback(() => {
if (tableIndex < 0) return;
// Replace the table with an empty string to delete it.
const newContent = replaceNthTable(memo.content, tableIndex, "");
updateMemo({
update: { name: memo.name, content: newContent },
updateMask: ["content"],
});
setDeleteDialogOpen(false);
}, [memo.content, memo.name, tableIndex, updateMemo]);
return (
<div className="my-2 w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
{children}
</table>
</div>
<>
<div ref={tableRef} className="group/table relative w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
{children}
</table>
{!readonly && (
<div className="absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/table:opacity-100 transition-all">
<button
type="button"
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
onClick={handleDeleteClick}
title="Delete table"
>
<TrashIcon className="size-3.5" />
</button>
<button
type="button"
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={handleEditClick}
title="Edit table"
>
<PencilIcon className="size-3.5" />
</button>
</div>
)}
</div>
<TableEditorDialog open={dialogOpen} onOpenChange={setDialogOpen} initialData={tableData} onConfirm={handleConfirmEdit} />
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Delete table</DialogTitle>
<DialogDescription>Are you sure you want to delete this table? This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button variant="destructive" onClick={handleConfirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
// ---------------------------------------------------------------------------
// Sub-components (unchanged)
// ---------------------------------------------------------------------------
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
children: React.ReactNode;
}

View File

@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions";
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
// Remove trigger char + word, then insert command output
// Remove trigger char + word.
actions.removeText(index, word.length);
// If the command has a dialog action, invoke it instead of inserting text.
if (cmd.action) {
cmd.action();
return;
}
// Otherwise insert the command output text.
actions.insertText(cmd.run());
// Position cursor relative to insertion point, if specified
// Position cursor relative to insertion point, if specified.
if (cmd.cursorOffset) {
actions.setCursorPosition(index + cmd.cursorOffset);
}

View File

@ -1,7 +1,10 @@
export interface Command {
name: string;
/** Returns text to insert. Ignored if `action` is set. */
run: () => string;
cursorOffset?: number;
/** If set, called instead of inserting run() text. Used for dialog-based commands. */
action?: () => void;
}
export const editorCommands: Command[] = [
@ -26,3 +29,18 @@ export const editorCommands: Command[] = [
cursorOffset: 1,
},
];
/**
* Create the full editor commands list, with the table command
* wired to open the table editor dialog instead of inserting raw markdown.
*/
export function createEditorCommands(onOpenTableEditor?: () => void): Command[] {
if (!onOpenTableEditor) return editorCommands;
return editorCommands.map((cmd) => {
if (cmd.name === "table") {
return { ...cmd, action: onOpenTableEditor };
}
return cmd;
});
}

View File

@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
isInIME = false,
onCompositionStart,
onCompositionEnd,
commands: customCommands,
} = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
@ -210,7 +211,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
onCompositionEnd={onCompositionEnd}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={customCommands || editorCommands} />
</div>
);
});

View File

@ -1,10 +1,21 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import {
FileIcon,
LinkIcon,
LoaderIcon,
type LucideIcon,
MapPinIcon,
Maximize2Icon,
MoreHorizontalIcon,
PlusIcon,
TableIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import { useReverseGeocoding } from "@/components/map";
import TableEditorDialog from "@/components/TableEditorDialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -30,6 +41,7 @@ const InsertMenu = (props: InsertMenuProps) => {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [tableDialogOpen, setTableDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
@ -86,6 +98,17 @@ const InsertMenu = (props: InsertMenuProps) => {
setLinkDialogOpen(true);
}, []);
const handleOpenTableDialog = useCallback(() => {
setTableDialogOpen(true);
}, []);
const handleTableConfirm = useCallback(
(markdown: string) => {
props.onInsertText?.(markdown);
},
[props],
);
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !locationInitialized) {
@ -129,6 +152,12 @@ const InsertMenu = (props: InsertMenuProps) => {
icon: FileIcon,
onClick: handleUploadClick,
},
{
key: "table",
label: "Table",
icon: TableIcon,
onClick: handleOpenTableDialog,
},
{
key: "link",
label: t("tooltip.link-memo"),
@ -142,7 +171,7 @@ const InsertMenu = (props: InsertMenuProps) => {
onClick: handleLocationClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, t],
[handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t],
);
return (
@ -209,6 +238,8 @@ const InsertMenu = (props: InsertMenuProps) => {
onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm}
/>
<TableEditorDialog open={tableDialogOpen} onOpenChange={setTableDialogOpen} onConfirm={handleTableConfirm} />
</>
);
};

View File

@ -1,11 +1,12 @@
import { forwardRef } from "react";
import { forwardRef, useMemo } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { createEditorCommands } from "../Editor/commands";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder, onOpenTableEditor }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
@ -54,6 +55,9 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
event.preventDefault();
};
// Build commands with the table editor action wired in.
const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]);
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
<Editor
@ -67,6 +71,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
commands={commands}
/>
</div>
);

View File

@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector";
import type { EditorToolbarProps } from "../types";
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName }) => {
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onInsertText }) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state);
@ -34,6 +34,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
location={state.metadata.location}
onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode}
onInsertText={onInsertText}
memoName={memoName}
/>
</div>

View File

@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import TableEditorDialog from "@/components/TableEditorDialog";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
@ -9,11 +10,23 @@ import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } from "./components";
import {
EditorContent,
EditorMetadata,
EditorToolbar,
FocusModeExitButton,
FocusModeOverlay,
TimestampPopover,
} from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services";
import {
cacheService,
errorService,
memoService,
validationService,
} from "./services";
import { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types";
@ -43,9 +56,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const memoName = memo?.name;
// Get default visibility from user settings
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
const defaultVisibility = userGeneralSetting?.memoVisibility
? convertVisibilityFromString(userGeneralSetting.memoVisibility)
: undefined;
useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
useMemoInit({
editorRef,
memo,
cacheKey,
username: currentUser?.name ?? "",
autoFocus,
defaultVisibility,
});
// Auto-save content to localStorage
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
@ -57,6 +79,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.toggleFocusMode());
};
// Table editor dialog (shared by slash command and toolbar).
const [tableDialogOpen, setTableDialogOpen] = useState(false);
const handleOpenTableEditor = useCallback(() => {
setTableDialogOpen(true);
}, []);
const handleTableConfirm = useCallback((markdown: string) => {
editorRef.current?.insertText(markdown);
}, []);
useKeyboard(editorRef, handleSave);
async function handleSave() {
@ -70,7 +103,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.setLoading("saving", true));
try {
const result = await memoService.save(state, { memoName, parentMemoName });
const result = await memoService.save(state, {
memoName,
parentMemoName,
});
if (!result.hasChanges) {
toast.error(t("editor.no-changes-detected"));
@ -89,12 +125,20 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Ensure memo detail pages don't keep stale cached content after edits.
if (memoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
invalidationPromises.push(
queryClient.invalidateQueries({
queryKey: memoKeys.detail(memoName),
}),
);
}
// If this was a comment, also invalidate the comments query for the parent memo
if (parentMemoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
invalidationPromises.push(
queryClient.invalidateQueries({
queryKey: memoKeys.comments(parentMemoName),
}),
);
}
await Promise.all(invalidationPromises);
@ -119,7 +163,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
return (
<>
<FocusModeOverlay isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} />
<FocusModeOverlay
isActive={state.ui.isFocusMode}
onToggle={handleToggleFocusMode}
/>
{/*
Layout structure:
@ -131,12 +178,20 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className={cn(
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border gap-2",
FOCUS_MODE_STYLES.transition,
state.ui.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
state.ui.isFocusMode &&
cn(
FOCUS_MODE_STYLES.container.base,
FOCUS_MODE_STYLES.container.spacing,
),
className,
)}
>
{/* Exit button is absolutely positioned in top-right corner when active */}
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
<FocusModeExitButton
isActive={state.ui.isFocusMode}
onToggle={handleToggleFocusMode}
title={t("editor.exit-focus-mode")}
/>
{memoName && (
<div className="w-full -mb-1">
@ -145,14 +200,29 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
)}
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} />
<EditorContent
ref={editorRef}
placeholder={placeholder}
onOpenTableEditor={handleOpenTableEditor}
/>
{/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2">
<EditorMetadata memoName={memoName} />
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} />
<EditorToolbar
onSave={handleSave}
onCancel={onCancel}
memoName={memoName}
onInsertText={(text) => editorRef.current?.insertText(text)}
/>
</div>
</div>
<TableEditorDialog
open={tableDialogOpen}
onOpenChange={setTableDialogOpen}
onConfirm={handleTableConfirm}
/>
</>
);
};

View File

@ -16,12 +16,14 @@ export interface MemoEditorProps {
export interface EditorContentProps {
placeholder?: string;
onOpenTableEditor?: () => void;
}
export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
onInsertText?: (text: string) => void;
}
export interface EditorMetadataProps {
@ -44,6 +46,7 @@ export interface InsertMenuProps {
location?: Location;
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
onInsertText?: (text: string) => void;
memoName?: string;
}
@ -68,6 +71,8 @@ export interface EditorProps {
isInIME?: boolean;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
/** Custom commands for slash menu. If not provided, defaults are used. */
commands?: import("../Editor/commands").Command[];
}
export interface VisibilitySelectorProps {

View File

@ -0,0 +1,411 @@
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;

View File

@ -0,0 +1,203 @@
/**
* Utilities for parsing, serializing, and manipulating markdown tables.
*/
export interface TableData {
headers: string[];
rows: string[][];
/** Column alignments: "left" | "center" | "right" | "none". */
alignments: ColumnAlignment[];
}
export type ColumnAlignment = "left" | "center" | "right" | "none";
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/**
* Parse a markdown table string into structured TableData.
*
* Expects a standard GFM table:
* | Header1 | Header2 |
* | ------- | ------- |
* | cell | cell |
*/
export function parseMarkdownTable(md: string): TableData | null {
const lines = md
.trim()
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length < 2) return null;
const parseRow = (line: string): string[] => {
// Strip leading/trailing pipes and split by pipe.
let trimmed = line;
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
return trimmed.split("|").map((cell) => cell.trim());
};
const headers = parseRow(lines[0]);
// Parse the separator line for alignments.
const sepCells = parseRow(lines[1]);
const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim()));
if (!isSeparator) return null;
const alignments: ColumnAlignment[] = sepCells.map((cell) => {
const c = cell.trim();
const left = c.startsWith(":");
const right = c.endsWith(":");
if (left && right) return "center";
if (right) return "right";
if (left) return "left";
return "none";
});
const rows: string[][] = [];
for (let i = 2; i < lines.length; i++) {
const cells = parseRow(lines[i]);
// Pad or trim to match header count.
while (cells.length < headers.length) cells.push("");
if (cells.length > headers.length) cells.length = headers.length;
rows.push(cells);
}
return { headers, rows, alignments };
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/**
* Serialize TableData into a properly-aligned markdown table string.
*/
export function serializeMarkdownTable(data: TableData): string {
const { headers, rows, alignments } = data;
const colCount = headers.length;
// Calculate maximum width per column (minimum 3 for the separator).
const widths: number[] = [];
for (let c = 0; c < colCount; c++) {
let max = Math.max(3, headers[c].length);
for (const row of rows) {
max = Math.max(max, (row[c] || "").length);
}
widths.push(max);
}
const padCell = (text: string, width: number, align: ColumnAlignment): string => {
const t = text || "";
const padding = width - t.length;
if (padding <= 0) return t;
if (align === "right") return " ".repeat(padding) + t;
if (align === "center") {
const left = Math.floor(padding / 2);
const right = padding - left;
return " ".repeat(left) + t + " ".repeat(right);
}
return t + " ".repeat(padding);
};
const formatRow = (cells: string[]): string => {
const formatted = cells.map((cell, i) => {
const align = alignments[i] || "none";
return padCell(cell, widths[i], align);
});
return "| " + formatted.join(" | ") + " |";
};
const separator = widths.map((w, i) => {
const align = alignments[i] || "none";
const dashes = "-".repeat(w);
if (align === "center") return ":" + dashes.slice(1, -1) + ":";
if (align === "right") return dashes.slice(0, -1) + ":";
if (align === "left") return ":" + dashes.slice(1);
return dashes;
});
const separatorLine = "| " + separator.join(" | ") + " |";
const headerLine = formatRow(headers);
const rowLines = rows.map((row) => formatRow(row));
return [headerLine, separatorLine, ...rowLines].join("\n");
}
// ---------------------------------------------------------------------------
// Find & Replace
// ---------------------------------------------------------------------------
/** Regex that matches a full markdown table block (one or more table lines). */
const TABLE_LINE = /^\s*\|.+\|\s*$/;
export interface TableMatch {
/** The raw markdown of the table. */
text: string;
/** Start index in the source string. */
start: number;
/** End index (exclusive) in the source string. */
end: number;
}
/**
* Find all markdown table blocks in a content string.
*/
export function findAllTables(content: string): TableMatch[] {
const lines = content.split("\n");
const tables: TableMatch[] = [];
let i = 0;
let offset = 0;
while (i < lines.length) {
if (TABLE_LINE.test(lines[i])) {
const startLine = i;
const startOffset = offset;
// Consume all consecutive table lines.
while (i < lines.length && TABLE_LINE.test(lines[i])) {
offset += lines[i].length + 1; // +1 for newline
i++;
}
const endOffset = offset - 1; // exclude trailing newline
const text = lines.slice(startLine, i).join("\n");
// Only count if it has at least a header + separator (2 lines).
if (i - startLine >= 2) {
tables.push({ text, start: startOffset, end: endOffset });
}
} else {
offset += lines[i].length + 1;
i++;
}
}
return tables;
}
/**
* Replace the nth table in the content with new markdown.
*/
export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string {
const tables = findAllTables(content);
if (tableIndex < 0 || tableIndex >= tables.length) return content;
const table = tables[tableIndex];
return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end);
}
// ---------------------------------------------------------------------------
// Default empty table
// ---------------------------------------------------------------------------
/**
* Create a default empty table with the given dimensions.
*/
export function createEmptyTable(cols = 2, rows = 2): TableData {
return {
headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`),
rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")),
alignments: Array.from({ length: cols }, () => "none" as ColumnAlignment),
};
}