diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 45d0cee93..0580fb7f6 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,20 +1,101 @@ +import { PencilIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import TableEditorDialog from "@/components/TableEditorDialog"; +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 button) +// --------------------------------------------------------------------------- + interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } export const Table = ({ children, className, node: _node, ...props }: TableProps) => { + const tableRef = useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [tableData, setTableData] = useState(null); + const [tableIndex, setTableIndex] = useState(-1); + + const { memo } = useMemoViewContext(); + const { readonly } = useMemoViewDerived(); + const { mutate: updateMemo } = useUpdateMemo(); + + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // Determine which table this is in the memo content by walking the DOM. + const container = tableRef.current?.closest('[class*="wrap-break-word"]'); + if (!container) return; + + const allTables = container.querySelectorAll("table"); + let idx = 0; + for (let i = 0; i < allTables.length; i++) { + if (tableRef.current?.contains(allTables[i])) { + idx = i; + break; + } + } + + // Find and parse the corresponding markdown table. + const tables = findAllTables(memo.content); + if (idx >= tables.length) return; + + const parsed = parseMarkdownTable(tables[idx].text); + if (!parsed) return; + + setTableData(parsed); + setTableIndex(idx); + setDialogOpen(true); + }, + [memo.content], + ); + + const handleConfirm = 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], + ); + return ( -
- - {children} -
-
+ <> +
+ + {children} +
+ {!readonly && ( + + )} +
+ + ); }; +// --------------------------------------------------------------------------- +// Sub-components (unchanged) +// --------------------------------------------------------------------------- + interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } diff --git a/web/src/components/MemoEditor/Editor/SlashCommands.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx index d1ab6c12d..023ad3670 100644 --- a/web/src/components/MemoEditor/Editor/SlashCommands.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -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); } diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts index 4aa58b44a..9f8b11254 100644 --- a/web/src/components/MemoEditor/Editor/commands.ts +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -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; + }); +} diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 0ef458f9e..4b7c4ed62 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward isInIME = false, onCompositionStart, onCompositionEnd, + commands: customCommands, } = props; const editorRef = useRef(null); @@ -205,7 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward onCompositionEnd={onCompositionEnd} > - + ); }); diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index ec962b5e7..6a3bd3b62 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -1,9 +1,20 @@ 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 { 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( @@ -77,6 +89,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 && !location.locationInitialized) { @@ -127,6 +150,12 @@ const InsertMenu = (props: InsertMenuProps) => { icon: FileIcon, onClick: handleUploadClick, }, + { + key: "table", + label: "Table", + icon: TableIcon, + onClick: handleOpenTableDialog, + }, { key: "link", label: t("tooltip.link-memo"), @@ -140,7 +169,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 ( @@ -207,6 +236,8 @@ const InsertMenu = (props: InsertMenuProps) => { onCancel={handleLocationCancel} onConfirm={handleLocationConfirm} /> + + ); }; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..41e8f2b39 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -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(({ placeholder }, ref) => { +export const EditorContent = forwardRef(({ placeholder, onOpenTableEditor }, ref) => { const { state, actions, dispatch } = useEditorContext(); const { createBlobUrl } = useBlobUrls(); @@ -54,6 +55,9 @@ export const EditorContent = forwardRef(({ event.preventDefault(); }; + // Build commands with the table editor action wired in. + const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]); + return (
(({ onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + commands={commands} />
); diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index ba03378ad..9c8781b2f 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName }) => { +export const EditorToolbar: FC = ({ 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 = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} + onInsertText={onInsertText} memoName={memoName} /> diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ec05a99d3..c0069b485 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -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"; @@ -68,6 +69,17 @@ const MemoEditorImpl: React.FC = ({ 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, { onSave: handleSave }); async function handleSave() { @@ -142,14 +154,21 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - + {/* Metadata and toolbar grouped together at bottom */}
- + editorRef.current?.insertText(text)} + />
+ + ); }; diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 13f91ff48..13bcf5917 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -18,12 +18,14 @@ export interface MemoEditorProps { export interface EditorContentProps { placeholder?: string; autoFocus?: boolean; + onOpenTableEditor?: () => void; } export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; + onInsertText?: (text: string) => void; } export interface EditorMetadataProps { @@ -68,6 +70,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; + onInsertText?: (text: string) => void; memoName?: string; } @@ -92,6 +95,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 { diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx new file mode 100644 index 000000000..54a52c383 --- /dev/null +++ b/web/src/components/TableEditorDialog.tsx @@ -0,0 +1,347 @@ +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { 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"; +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"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Initial table data when editing an existing table. */ + initialData?: TableData | null; + /** Called with the formatted markdown table string on confirm. */ + 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); + + // Ref grid for Tab navigation: inputRefs[row][col] (row -1 = headers). + 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); + } + }, []); + + // Initialize state when dialog opens. + 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 ---- + + const addColumn = () => { + setHeaders((prev) => [...prev, ""]); + setRows((prev) => prev.map((r) => [...r, ""])); + setAlignments((prev) => [...prev, "none"]); + 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 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(); + // Try numeric comparison first. + 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") { + e.preventDefault(); + const nextCol = e.shiftKey ? col - 1 : col + 1; + let nextRow = row; + + if (nextCol >= colCount) { + // Move to first cell of next row. + if (row < rowCount - 1) { + nextRow = row + 1; + focusCell(nextRow, 0); + } else { + // At last cell – add a new row and focus it. + addRow(); + // Need to wait for state update; use setTimeout. + setTimeout(() => focusCell(rowCount, 0), 0); + } + } else if (nextCol < 0) { + // Move to last cell of previous row. + if (row > 0) { + nextRow = row - 1; + focusCell(nextRow, colCount - 1); + } else { + // Move to header row. + 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(); + }; + + // ---- Confirm ---- + + const handleConfirm = () => { + const data: TableData = { headers, rows, alignments }; + const md = serializeMarkdownTable(data); + onConfirm(md); + onOpenChange(false); + }; + + // ---- Sort indicator ---- + + const SortIndicator = ({ col }: { col: number }) => { + if (sortState?.col === col) { + return sortState.dir === "asc" ? : ; + } + return ; + }; + + return ( + + + + + + + Table Editor + + + Edit table headers, rows, columns and sort data + +
+ {/* Scrollable table area */} +
+ + {/* Header row */} + + + {/* Row number column */} + + ))} + {/* Add column button */} + + + + {/* Data rows */} + + {rows.map((row, rowIdx) => ( + + {/* Row number + remove */} + + {row.map((cell, col) => ( + + ))} + + ))} + +
+ {headers.map((header, col) => ( + +
+
+ setInputRef(`-1:${col}`, el)} + 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" + 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 + +
+
+ {rowIdx + 1} + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
+ setInputRef(`${rowIdx}:${col}`, el)} + 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)} + placeholder="..." + /> + +
+ + {/* Add row button */} +
+ +
+
+ + {/* Footer */} +
+ + {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} + +
+ + +
+
+
+
+
+ ); +}; + +export default TableEditorDialog; diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts new file mode 100644 index 000000000..1e6368176 --- /dev/null +++ b/web/src/utils/markdown-table.ts @@ -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), + }; +}