From b4c2c180b80d0a8cb6211c8fb478dbe85a7c3970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Vasi=C4=87?= Date: Tue, 3 Mar 2026 23:04:46 +0100 Subject: [PATCH 01/11] 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 --- web/src/components/MemoContent/Table.tsx | 147 ++++++- .../MemoEditor/Editor/SlashCommands.tsx | 12 +- .../components/MemoEditor/Editor/commands.ts | 18 + .../components/MemoEditor/Editor/index.tsx | 3 +- .../MemoEditor/Toolbar/InsertMenu.tsx | 35 +- .../MemoEditor/components/EditorContent.tsx | 9 +- .../MemoEditor/components/EditorToolbar.tsx | 3 +- web/src/components/MemoEditor/index.tsx | 96 +++- .../components/MemoEditor/types/components.ts | 5 + web/src/components/TableEditorDialog.tsx | 411 ++++++++++++++++++ web/src/utils/markdown-table.ts | 203 +++++++++ 11 files changed, 916 insertions(+), 26 deletions(-) create mode 100644 web/src/components/TableEditorDialog.tsx create mode 100644 web/src/utils/markdown-table.ts diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 69e967cdd..f6b9974c6 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -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, ReactMarkdownProps { children: React.ReactNode; } export const Table = ({ children, className, node: _node, ...props }: TableProps) => { + const tableRef = useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [tableData, setTableData] = useState(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 ( -
- - {children} -
-
+ <> +
+ + {children} +
+ {!readonly && ( +
+ + +
+ )} +
+ + + + {/* Delete confirmation dialog */} + + + + Delete table + Are you sure you want to delete this table? This action cannot be undone. + + + + + + + + + + ); }; +// --------------------------------------------------------------------------- +// 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 6cce83cc9..a7dfd728f 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); @@ -210,7 +211,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 a3bd05fae..790e7cca5 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -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} /> + + ); }; 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 bfbbc8775..74c94d0f9 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"; @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ // 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 = ({ return ( <> - + {/* Layout structure: @@ -131,12 +178,20 @@ const MemoEditorImpl: React.FC = ({ 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 */} - + {memoName && (
@@ -145,14 +200,29 @@ 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 e99519d1f..d93737a52 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -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 { diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx new file mode 100644 index 000000000..888509442 --- /dev/null +++ b/web/src/components/TableEditorDialog.tsx @@ -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([]); + const [rows, setRows] = useState([]); + const [alignments, setAlignments] = useState([]); + const [sortState, setSortState] = useState(null); + + 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); + }, []); + + 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, 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" ? : ; + } + return ; + }; + + const totalColSpan = colCount + 2; + + return ( + + + + + + + Table Editor + + + Edit table headers, rows, columns and sort data + + +
+ {/* Scrollable table area */} +
+ {/* Wrapper: w-max + overflow-x-clip so row insert line is clipped (clip avoids breaking sticky); min-w-full so table fills when narrow */} +
+ + {/* ============ STICKY HEADER ============ */} + + {/* Mask row: solid background that hides content scrolling behind the header */} + + + + {/* Header row */} + + {/* Row-number spacer */} + + ))} + + {/* Add column at end */} + + + + + {/* ============ DATA ROWS ============ */} + + {rows.map((row, rowIdx) => ( + + + {/* Row number — with insert-row zone on top border */} + + + {/* Data cells */} + {row.map((cell, col) => ( + + ))} + + {/* Row delete button */} + + + + ))} + +
+
+ + {headers.map((header, col) => ( + + {/* ---- Insert-column zone (left edge of this column) ---- */} +
insertColumnAt(col)} + > + {/* Blue vertical line through the entire table */} +
+ {/* + button — absolutely centered on the column border */} + + + + + Insert column + +
+ + {/* Header cell — bg covers input + sort + delete */} +
+ 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}`} + /> + + + + + Sort column + + {colCount > 1 && ( + + + + + Remove column + + )} +
+
+ + + + + Add column + +
+
insertRowAt(rowIdx)} + > + {/* Blue horizontal line extending across the table */} +
+ {/* + button at intersection of row border and first-column border */} + + + + + Insert row + +
+ {rowIdx + 1} +
+ 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)} + /> + + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
+ + {/* Add row button below the table */} +
+ +
+
+ + {/* ============ 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), + }; +} From 0aada4230c59a1962cc941d9e6b715145e8c27cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Vasi=C4=87?= Date: Wed, 4 Mar 2026 08:38:46 +0100 Subject: [PATCH 02/11] Lint run --- web/src/components/TableEditorDialog.tsx | 84 ++++++++++++------------ 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 888509442..813c265a2 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -217,28 +217,28 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {headers.map((header, col) => ( {/* ---- Insert-column zone (left edge of this column) ---- */} +
insertColumnAt(col)} + > + {/* Blue vertical line through the entire table */}
insertColumnAt(col)} - > - {/* Blue vertical line through the entire table */} -
- {/* + button — absolutely centered on the column border */} - - - - - Insert column - -
+ 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 */} + + + + + Insert column + +
{/* Header cell — bg covers input + sort + delete */}
@@ -306,28 +306,28 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {/* Row number — with insert-row zone on top border */} +
insertRowAt(rowIdx)} + > + {/* Blue horizontal line extending across the table */}
insertRowAt(rowIdx)} - > - {/* Blue horizontal line extending across the table */} -
- {/* + button at intersection of row border and first-column border */} - - - - - Insert row - -
+ 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 */} + + + + + Insert row + +
{rowIdx + 1} From a759acc6a7c45ec66ae2c03a6d6d4e5c0c571581 Mon Sep 17 00:00:00 2001 From: milvasic Date: Wed, 11 Mar 2026 21:21:45 +0100 Subject: [PATCH 03/11] fix: address PR review comments for table editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - Fix replaceNthTable off-by-one: findAllTables now uses truly exclusive end index (start + text.length) so content.slice(start, end) === text - Replace fragile DOM-based table index resolution with AST-based approach using node.position.start.offset from hast ReactMarkdownProps Architecture: - Unify TableEditorDialog instances: InsertMenu no longer manages its own dialog, instead calls onOpenTableEditor from parent MemoEditor which owns the single shared dialog instance - Remove onInsertText prop chain (InsertMenu → EditorToolbar → MemoEditor) replaced by onOpenTableEditor Other improvements: - Add i18n: all hardcoded English strings now use useTranslate()/t() with new editor.table.* keys in en.json - Fix useCallback [props] dependency that defeated memoization (removed with dialog unification) - Use stable row IDs (monotonic counter) as React keys instead of array indices in TableEditorDialog - Replace hardcoded MONO_FONT constant with Tailwind font-mono class (maps to project's --font-mono CSS variable) - Add 28 vitest tests for markdown-table.ts covering parse, serialize, findAllTables, replaceNthTable, createEmptyTable with edge cases - Add vitest dev dependency with test/test:watch scripts --- AGENTS.md | 1 + web/package.json | 7 +- web/pnpm-lock.yaml | 224 +++++++++++++ web/src/components/MemoContent/Table.tsx | 49 ++- .../MemoEditor/Toolbar/InsertMenu.tsx | 23 +- .../MemoEditor/components/EditorToolbar.tsx | 4 +- web/src/components/MemoEditor/index.tsx | 7 +- .../components/MemoEditor/types/components.ts | 4 +- web/src/components/TableEditorDialog.tsx | 85 ++--- web/src/locales/en.json | 20 +- web/src/utils/markdown-table.test.ts | 308 ++++++++++++++++++ web/src/utils/markdown-table.ts | 3 +- 12 files changed, 639 insertions(+), 96 deletions(-) create mode 100644 web/src/utils/markdown-table.test.ts diff --git a/AGENTS.md b/AGENTS.md index 892553282..b68707705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ pnpm dev # Dev server (:3001, proxies API to :8081) pnpm lint # Type check + Biome lint pnpm lint:fix # Auto-fix lint issues pnpm format # Format code +pnpm test # Run frontend tests pnpm build # Production build pnpm release # Build to server/router/frontend/dist diff --git a/web/package.json b/web/package.json index 561114057..65c139410 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,9 @@ "release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir", "lint": "tsc --noEmit --skipLibCheck && biome check src", "lint:fix": "biome check --write src", - "format": "biome format --write src" + "format": "biome format --write src", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@connectrpc/connect": "^2.1.1", @@ -92,7 +94,8 @@ "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.1" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 39a13a1f3..63a6c94b7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: vite: specifier: ^7.2.4 version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + vitest: + specifier: ^4.1.1 + version: 4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) packages: @@ -1249,6 +1252,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1372,6 +1378,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1468,6 +1477,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1548,6 +1560,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} + + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} + + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} + + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} + + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} + + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -1560,6 +1601,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -1590,6 +1635,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1878,6 +1927,9 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1898,6 +1950,13 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2407,6 +2466,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} @@ -2665,6 +2727,9 @@ packages: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} engines: {node: '>=6.9'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2690,6 +2755,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -2699,6 +2767,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2740,6 +2811,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2748,6 +2822,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} @@ -2884,6 +2962,41 @@ packages: yaml: optional: true + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2911,6 +3024,11 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3842,6 +3960,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3946,6 +4066,11 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -4067,6 +4192,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -4148,6 +4275,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.1': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + + '@vitest/pretty-format@4.1.1': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.1': + dependencies: + '@vitest/utils': 4.1.1 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.1': {} + + '@vitest/utils@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xobotyi/scrollbar-width@1.9.5': {} acorn@8.15.0: {} @@ -4156,6 +4324,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -4182,6 +4352,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4488,6 +4660,8 @@ snapshots: dependencies: stackframe: 1.3.4 + es-module-lexer@2.0.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4525,6 +4699,12 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -5286,6 +5466,8 @@ snapshots: node-releases@2.0.27: {} + obug@2.1.1: {} + package-manager-detector@1.5.0: {} parent-module@1.0.1: @@ -5622,6 +5804,8 @@ snapshots: set-harmonic-interval@1.0.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5641,6 +5825,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -5654,6 +5840,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + std-env@4.0.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5690,6 +5878,8 @@ snapshots: throttle-debounce@3.0.1: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5697,6 +5887,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + toggle-selection@1.0.6: {} trim-lines@3.0.1: {} @@ -5813,6 +6005,33 @@ snapshots: lightningcss: 1.31.1 terser: 5.46.1 + vitest@4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -5834,6 +6053,11 @@ snapshots: web-namespaces@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} yaml@1.10.2: {} diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index f6b9974c6..7665b9868 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,10 +1,11 @@ import { PencilIcon, TrashIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, 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 { useTranslate } from "@/utils/i18n"; import type { TableData } from "@/utils/markdown-table"; import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table"; import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; @@ -18,8 +19,8 @@ interface TableProps extends React.HTMLAttributes, ReactMarkdo children: React.ReactNode; } -export const Table = ({ children, className, node: _node, ...props }: TableProps) => { - const tableRef = useRef(null); +export const Table = ({ children, className, node, ...props }: TableProps) => { + const t = useTranslate(); const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableData, setTableData] = useState(null); @@ -29,32 +30,31 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps 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; + /** Resolve which markdown table index this rendered table corresponds to using AST source positions. */ + const resolveTableIndex = useMemo(() => { + const nodeStart = node?.position?.start?.offset; + if (nodeStart == null) return -1; - const allTables = container.querySelectorAll("table"); - for (let i = 0; i < allTables.length; i++) { - if (tableRef.current?.contains(allTables[i])) return i; + const tables = findAllTables(memo.content); + for (let i = 0; i < tables.length; i++) { + if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i; } return -1; - }, []); + }, [memo.content, node]); 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; + if (resolveTableIndex < 0 || resolveTableIndex >= tables.length) return; - const parsed = parseMarkdownTable(tables[idx].text); + const parsed = parseMarkdownTable(tables[resolveTableIndex].text); if (!parsed) return; setTableData(parsed); - setTableIndex(idx); + setTableIndex(resolveTableIndex); setDialogOpen(true); }, [memo.content, resolveTableIndex], @@ -65,10 +65,9 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps e.stopPropagation(); e.preventDefault(); - const idx = resolveTableIndex(); - if (idx < 0) return; + if (resolveTableIndex < 0) return; - setTableIndex(idx); + setTableIndex(resolveTableIndex); setDeleteDialogOpen(true); }, [resolveTableIndex], @@ -99,7 +98,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps return ( <> -
+
{children}
@@ -109,7 +108,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps 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" + title={t("common.delete")} > @@ -117,7 +116,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps 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" + title={t("common.edit")} > @@ -131,15 +130,15 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps - Delete table - Are you sure you want to delete this table? This action cannot be undone. + {t("editor.table.delete")} + {t("editor.table.delete-confirm")} - + diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 790e7cca5..3bf4ac15b 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -15,7 +15,6 @@ 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, @@ -37,11 +36,10 @@ import type { LocalFile } from "../types/attachment"; const InsertMenu = (props: InsertMenuProps) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); - const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props; + const { location: initialLocation, onLocationChange, onToggleFocusMode, onOpenTableEditor, isUploading: isUploadingProp } = props; 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( @@ -98,17 +96,6 @@ 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) { @@ -154,9 +141,9 @@ const InsertMenu = (props: InsertMenuProps) => { }, { key: "table", - label: "Table", + label: t("editor.table.title"), icon: TableIcon, - onClick: handleOpenTableDialog, + onClick: () => onOpenTableEditor?.(), }, { key: "link", @@ -171,7 +158,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t], + [handleLocationClick, handleOpenLinkDialog, onOpenTableEditor, handleUploadClick, t], ); return ( @@ -238,8 +225,6 @@ const InsertMenu = (props: InsertMenuProps) => { onCancel={handleLocationCancel} onConfirm={handleLocationConfirm} /> - - ); }; diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index 9c8781b2f..c2d5ccac2 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, onInsertText }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onOpenTableEditor }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -34,7 +34,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} - onInsertText={onInsertText} + onOpenTableEditor={onOpenTableEditor} memoName={memoName} />
diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 74c94d0f9..483e3ad6e 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -209,12 +209,7 @@ const MemoEditorImpl: React.FC = ({ {/* 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 d93737a52..eb0b741e6 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -23,7 +23,7 @@ export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; - onInsertText?: (text: string) => void; + onOpenTableEditor?: () => void; } export interface EditorMetadataProps { @@ -46,7 +46,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; - onInsertText?: (text: string) => void; + onOpenTableEditor?: () => void; memoName?: string; } diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 813c265a2..f04888657 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -1,5 +1,6 @@ import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslate } from "@/utils/i18n"; import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; import { Button } from "./ui/button"; @@ -7,12 +8,6 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr 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 // --------------------------------------------------------------------------- @@ -31,12 +26,17 @@ type SortState = { col: number; dir: "asc" | "desc" } | null; // --------------------------------------------------------------------------- const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => { + const t = useTranslate(); const [headers, setHeaders] = useState([]); const [rows, setRows] = useState([]); + const [rowIds, setRowIds] = useState([]); const [alignments, setAlignments] = useState([]); const [sortState, setSortState] = useState(null); const inputRefs = useRef>(new Map()); + const nextRowId = useRef(0); + + const allocateRowId = useCallback(() => nextRowId.current++, []); const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { if (el) inputRefs.current.set(key, el); @@ -45,14 +45,17 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table useEffect(() => { if (open) { + nextRowId.current = 0; if (initialData) { setHeaders([...initialData.headers]); setRows(initialData.rows.map((r) => [...r])); + setRowIds(initialData.rows.map(() => nextRowId.current++)); setAlignments([...initialData.alignments]); } else { const empty = createEmptyTable(3, 2); setHeaders(empty.headers); setRows(empty.rows); + setRowIds(empty.rows.map(() => nextRowId.current++)); setAlignments(empty.alignments); } setSortState(null); @@ -105,16 +108,21 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table }; const addRow = () => { + const id = allocateRowId(); setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); + setRowIds((prev) => [...prev, id]); }; const insertRowAt = (index: number) => { + const id = allocateRowId(); setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]); + setRowIds((prev) => [...prev.slice(0, index), id, ...prev.slice(index)]); }; const removeRow = (row: number) => { if (rowCount <= 1) return; setRows((prev) => prev.filter((_, i) => i !== row)); + setRowIds((prev) => prev.filter((_, i) => i !== row)); }; // ---- Sorting ---- @@ -123,18 +131,22 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table 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; - }); + + const compareFn = (a: string[], b: string[]) => { + 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; + }; + + // Build sorted indices, then apply to both rows and rowIds. + const indices = rows.map((_, i) => i); + indices.sort((a, b) => compareFn(rows[a], rows[b])); + setRows(indices.map((i) => rows[i])); + setRowIds(indices.map((i) => rowIds[i])); }; // ---- Tab / keyboard navigation ---- @@ -190,10 +202,10 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Table Editor + {t("editor.table.editor-title")} - Edit table headers, rows, columns and sort data + {t("editor.table.editor-description")}
@@ -236,7 +248,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Insert column + {t("editor.table.insert-column")}
@@ -244,8 +256,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
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" + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent font-mono 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)} @@ -261,7 +272,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Sort column + {t("editor.table.sort-column")} {colCount > 1 && ( @@ -274,7 +285,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Remove column + {t("editor.table.remove-column")} )}
@@ -293,7 +304,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Add column + {t("editor.table.add-column")} @@ -302,7 +313,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {/* ============ DATA ROWS ============ */} {rows.map((row, rowIdx) => ( - + {/* Row number — with insert-row zone on top border */} @@ -325,7 +336,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Insert row + {t("editor.table.insert-row")}
{rowIdx + 1} @@ -336,8 +347,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table 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" + className="w-full px-2 py-1.5 text-sm bg-transparent font-mono 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)} @@ -358,7 +368,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Remove row + {t("editor.table.remove-row")} )} @@ -373,7 +383,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
@@ -382,23 +392,24 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
- {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} + {colCount} {colCount === 1 ? t("editor.table.column") : t("editor.table.columns")} · {rowCount}{" "} + {rowCount === 1 ? t("editor.table.row") : t("editor.table.rows")}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f19d88933..f2a6f23d5 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -124,7 +124,25 @@ "no-changes-detected": "No changes detected", "save": "Save", "saving": "Saving...", - "slash-commands": "Type `/` for commands" + "slash-commands": "Type `/` for commands", + "table": { + "title": "Table", + "editor-title": "Table Editor", + "editor-description": "Edit table headers, rows, columns and sort data", + "add-row": "Add row", + "add-column": "Add column", + "insert-column": "Insert column", + "insert-row": "Insert row", + "remove-column": "Remove column", + "remove-row": "Remove row", + "sort-column": "Sort column", + "delete": "Delete table", + "delete-confirm": "Are you sure you want to delete this table? This action cannot be undone.", + "column": "column", + "columns": "columns", + "row": "row", + "rows": "rows" + } }, "inbox": { "failed-to-load": "Failed to load inbox item", diff --git a/web/src/utils/markdown-table.test.ts b/web/src/utils/markdown-table.test.ts new file mode 100644 index 000000000..229ace880 --- /dev/null +++ b/web/src/utils/markdown-table.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import { + createEmptyTable, + findAllTables, + parseMarkdownTable, + replaceNthTable, + serializeMarkdownTable, + type TableData, +} from "./markdown-table"; + +// --------------------------------------------------------------------------- +// parseMarkdownTable +// --------------------------------------------------------------------------- + +describe("parseMarkdownTable", () => { + it("parses a basic table", () => { + const md = `| A | B | +| --- | --- | +| 1 | 2 | +| 3 | 4 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + expect(result!.alignments).toEqual(["none", "none"]); + }); + + it("parses alignment markers", () => { + const md = `| Left | Center | Right | None | +| :--- | :---: | ---: | --- | +| a | b | c | d |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.alignments).toEqual(["left", "center", "right", "none"]); + }); + + it("returns null for non-table text", () => { + expect(parseMarkdownTable("hello world")).toBeNull(); + }); + + it("returns null for a single line", () => { + expect(parseMarkdownTable("| A | B |")).toBeNull(); + }); + + it("returns null when separator is invalid", () => { + const md = `| A | B | +| not | valid |`; + expect(parseMarkdownTable(md)).toBeNull(); + }); + + it("pads short rows to match header count", () => { + const md = `| A | B | C | +| --- | --- | --- | +| 1 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["1", "", ""]); + }); + + it("trims long rows to match header count", () => { + const md = `| A | B | +| --- | --- | +| 1 | 2 | 3 | 4 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["1", "2"]); + }); + + it("handles empty cells", () => { + const md = `| A | B | +| --- | --- | +| | |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["", ""]); + }); + + it("handles table with no data rows", () => { + const md = `| A | B | +| --- | --- |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// serializeMarkdownTable +// --------------------------------------------------------------------------- + +describe("serializeMarkdownTable", () => { + it("serializes a basic table", () => { + const data: TableData = { + headers: ["A", "B"], + rows: [ + ["1", "2"], + ["3", "4"], + ], + alignments: ["none", "none"], + }; + const result = serializeMarkdownTable(data); + expect(result).toContain("| A"); + expect(result).toContain("| 1"); + // Should have 4 lines: header, separator, 2 data rows + expect(result.split("\n")).toHaveLength(4); + }); + + it("preserves alignment in separator", () => { + const data: TableData = { + headers: ["Left", "Center", "Right"], + rows: [["a", "b", "c"]], + alignments: ["left", "center", "right"], + }; + const result = serializeMarkdownTable(data); + const lines = result.split("\n"); + const sep = lines[1]; + // Left alignment: starts with ":" + expect(sep).toMatch(/\| :-+\s/); + // Center alignment: starts and ends with ":" + expect(sep).toMatch(/:-+:/); + // Right alignment: ends with ":" + expect(sep).toMatch(/-+: \|$/); + }); + + it("pads cells to uniform width", () => { + const data: TableData = { + headers: ["Short", "A very long header"], + rows: [["x", "y"]], + alignments: ["none", "none"], + }; + const result = serializeMarkdownTable(data); + const lines = result.split("\n"); + // All lines should have same length due to padding + expect(new Set(lines.map((l) => l.length)).size).toBe(1); + }); + + it("round-trips through parse and serialize", () => { + const original = `| Name | Age | +| ----- | --- | +| Alice | 30 | +| Bob | 25 |`; + const parsed = parseMarkdownTable(original); + expect(parsed).not.toBeNull(); + const serialized = serializeMarkdownTable(parsed!); + const reparsed = parseMarkdownTable(serialized); + expect(reparsed).not.toBeNull(); + expect(reparsed!.headers).toEqual(parsed!.headers); + expect(reparsed!.rows).toEqual(parsed!.rows); + expect(reparsed!.alignments).toEqual(parsed!.alignments); + }); +}); + +// --------------------------------------------------------------------------- +// findAllTables +// --------------------------------------------------------------------------- + +describe("findAllTables", () => { + it("finds a single table", () => { + const content = `Some text + +| A | B | +| --- | --- | +| 1 | 2 | + +More text`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(tables[0].text).toContain("| A | B |"); + // Verify start/end are correct by slicing + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); + + it("finds multiple tables", () => { + const content = `| A | B | +| --- | --- | +| 1 | 2 | + +Some text between + +| X | Y | +| --- | --- | +| 3 | 4 |`; + const tables = findAllTables(content); + expect(tables).toHaveLength(2); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + expect(content.slice(tables[1].start, tables[1].end)).toBe(tables[1].text); + }); + + it("returns empty for no tables", () => { + expect(findAllTables("just some text\nno tables here")).toHaveLength(0); + }); + + it("requires at least 2 lines for a table", () => { + const content = "| single line |"; + expect(findAllTables(content)).toHaveLength(0); + }); + + it("handles table at end of content without trailing newline", () => { + const content = `text +| A | B | +| --- | --- | +| 1 | 2 |`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); + + it("handles table at start of content", () => { + const content = `| A | B | +| --- | --- | +| 1 | 2 | +more text`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(tables[0].start).toBe(0); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); +}); + +// --------------------------------------------------------------------------- +// replaceNthTable +// --------------------------------------------------------------------------- + +describe("replaceNthTable", () => { + const content = `Before + +| A | B | +| --- | --- | +| 1 | 2 | + +Middle + +| X | Y | +| --- | --- | +| 3 | 4 | + +After`; + + it("replaces the first table", () => { + const result = replaceNthTable(content, 0, "NEW TABLE"); + expect(result).toContain("NEW TABLE"); + expect(result).toContain("| X | Y |"); + expect(result).not.toContain("| A | B |"); + }); + + it("replaces the second table", () => { + const result = replaceNthTable(content, 1, "NEW TABLE"); + expect(result).toContain("| A | B |"); + expect(result).toContain("NEW TABLE"); + expect(result).not.toContain("| X | Y |"); + }); + + it("deletes a table when replacing with empty string", () => { + const result = replaceNthTable(content, 0, ""); + expect(result).not.toContain("| A | B |"); + expect(result).toContain("Before"); + expect(result).toContain("Middle"); + }); + + it("returns content unchanged for invalid index", () => { + expect(replaceNthTable(content, -1, "X")).toBe(content); + expect(replaceNthTable(content, 99, "X")).toBe(content); + }); +}); + +// --------------------------------------------------------------------------- +// createEmptyTable +// --------------------------------------------------------------------------- + +describe("createEmptyTable", () => { + it("creates table with specified dimensions", () => { + const table = createEmptyTable(3, 2); + expect(table.headers).toHaveLength(3); + expect(table.rows).toHaveLength(2); + expect(table.alignments).toHaveLength(3); + expect(table.rows[0]).toHaveLength(3); + }); + + it("creates default 2x2 table", () => { + const table = createEmptyTable(); + expect(table.headers).toHaveLength(2); + expect(table.rows).toHaveLength(2); + }); + + it("initializes with header placeholders", () => { + const table = createEmptyTable(2, 1); + expect(table.headers[0]).toBe("Header 1"); + expect(table.headers[1]).toBe("Header 2"); + }); + + it("initializes cells as empty strings", () => { + const table = createEmptyTable(2, 2); + for (const row of table.rows) { + for (const cell of row) { + expect(cell).toBe(""); + } + } + }); + + it("initializes all alignments to none", () => { + const table = createEmptyTable(3, 1); + expect(table.alignments).toEqual(["none", "none", "none"]); + }); +}); diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts index 1e6368176..f2099a8bc 100644 --- a/web/src/utils/markdown-table.ts +++ b/web/src/utils/markdown-table.ts @@ -161,11 +161,10 @@ export function findAllTables(content: string): TableMatch[] { 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 }); + tables.push({ text, start: startOffset, end: startOffset + text.length }); } } else { offset += lines[i].length + 1; From 9cbf5ebac4bcf36ac4afa846952cbc9389151a18 Mon Sep 17 00:00:00 2001 From: milvasic Date: Wed, 11 Mar 2026 21:48:17 +0100 Subject: [PATCH 04/11] fix: forward node prop to Table component for AST-based index resolution\n\nThe table component mapping in MemoContent discarded the `node` prop\nfrom react-markdown, so `node.position.start.offset` was always\nundefined and the edit button never opened the table editor." --- web/src/components/MemoContent/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 50ecfb8ca..90ecc48ab 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -124,10 +124,14 @@ const MemoContent = (props: MemoContentProps) => { // Code blocks pre: CodeBlock, // Tables - table: ({ children, ...props }) => {children}
, - thead: ({ children, ...props }) => {children}, - tbody: ({ children, ...props }) => {children}, - tr: ({ children, ...props }) => {children}, + table: ({ children, node, ...props }) => ( + + {children} +
+ ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, th: ({ children, ...props }) => {children}, td: ({ children, ...props }) => {children}, }} From 651c4085bf67756994323232ca52bf7ab8e232e4 Mon Sep 17 00:00:00 2001 From: milvasic Date: Mon, 23 Mar 2026 18:51:29 +0100 Subject: [PATCH 05/11] pnpm format --- web/src/components/MemoEditor/index.tsx | 49 ++++--------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 483e3ad6e..163d52b60 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -10,23 +10,11 @@ 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"; @@ -56,9 +44,7 @@ const MemoEditorImpl: React.FC = ({ 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, @@ -163,10 +149,7 @@ const MemoEditorImpl: React.FC = ({ return ( <> - + {/* Layout structure: @@ -178,20 +161,12 @@ const MemoEditorImpl: React.FC = ({ 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 */} - + {memoName && (
@@ -200,11 +175,7 @@ const MemoEditorImpl: React.FC = ({ )} {/* Editor content grows to fill available space in focus mode */} - + {/* Metadata and toolbar grouped together at bottom */}
@@ -213,11 +184,7 @@ const MemoEditorImpl: React.FC = ({
- + ); }; From 314ab037151681b0a0f6988afcbe80055bbc7548 Mon Sep 17 00:00:00 2001 From: milvasic Date: Mon, 23 Mar 2026 19:22:01 +0100 Subject: [PATCH 06/11] fix(table): use GFM AST parser for findAllTables; fix lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace regex-based TABLE_LINE scan in findAllTables with a proper GFM markdown AST lookup using mdast-util-from-markdown + mdast-util-gfm + micromark-extension-gfm + unist-util-visit (all already in deps). Tables without leading/trailing pipes (e.g. 'A | B\n--- | ---\n1 | 2') are now correctly detected via node.position.start/end.offset. - Add regression test asserting pipe-less GFM table is found with correct start/end offsets. - Fix parseMarkdownTable to split on unescaped pipes only (lookbehind). - Fix import order in Table.tsx (Biome organizeImports). - Fix useless escape \- → - in TableEditorDialog.tsx sort regex. - Reformat long normalize/compareFn lines to satisfy Biome formatter. --- web/src/components/MemoContent/Table.tsx | 34 ++++++++++------ web/src/components/TableEditorDialog.tsx | 30 +++++++++----- web/src/utils/markdown-table.test.ts | 7 ++++ web/src/utils/markdown-table.ts | 50 ++++++++++-------------- 4 files changed, 70 insertions(+), 51 deletions(-) diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 7665b9868..e6686c14c 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,9 +1,11 @@ import { PencilIcon, TrashIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; +import toast from "react-hot-toast"; 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 { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import type { TableData } from "@/utils/markdown-table"; @@ -28,7 +30,7 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { const { memo } = useMemoViewContext(); const { readonly } = useMemoViewDerived(); - const { mutate: updateMemo } = useUpdateMemo(); + const { mutateAsync: updateMemo } = useUpdateMemo(); /** Resolve which markdown table index this rendered table corresponds to using AST source positions. */ const resolveTableIndex = useMemo(() => { @@ -74,26 +76,34 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { ); const handleConfirmEdit = useCallback( - (markdown: string) => { + async (markdown: string) => { if (tableIndex < 0) return; const newContent = replaceNthTable(memo.content, tableIndex, markdown); - updateMemo({ - update: { name: memo.name, content: newContent }, - updateMask: ["content"], - }); + try { + await updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + } catch (error: unknown) { + handleError(error, toast.error, { context: "Update table", fallbackMessage: "An error occurred" }); + } }, [memo.content, memo.name, tableIndex, updateMemo], ); - const handleConfirmDelete = useCallback(() => { + const handleConfirmDelete = useCallback(async () => { 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); + try { + await updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + setDeleteDialogOpen(false); + } catch (error: unknown) { + handleError(error, toast.error, { context: "Delete table", fallbackMessage: "An error occurred" }); + } }, [memo.content, memo.name, tableIndex, updateMemo]); return ( diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index f04888657..7c0656902 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -132,12 +132,22 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; setSortState({ col, dir: newDir }); + const normalize = (s: string): string => + s + .trim() + .replace(/[^\d.-]/g, "") + .replace(/(?<=.)-/g, ""); const compareFn = (a: string[], b: string[]) => { 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 ca = normalize(va); + const cb = normalize(vb); + const na = ca !== "" ? parseFloat(ca) : NaN; + const nb = cb !== "" ? parseFloat(cb) : NaN; + const aIsNum = Number.isFinite(na); + const bIsNum = Number.isFinite(nb); + if (aIsNum && bIsNum) return newDir === "asc" ? na - nb : nb - na; + if (aIsNum !== bIsNum) return aIsNum ? (newDir === "asc" ? 1 : -1) : newDir === "asc" ? -1 : 1; const cmp = va.localeCompare(vb); return newDir === "asc" ? cmp : -cmp; }; @@ -153,17 +163,17 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { if (e.key !== "Tab") return; + + // At grid boundaries, let the browser move focus naturally out of the grid. + if (!e.shiftKey && row === rowCount - 1 && col === colCount - 1) return; + if (e.shiftKey && row === 0 && col === 0) 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); - } + nextRow = row + 1; + focusCell(nextRow, 0); } else if (nextCol < 0) { if (row > 0) { nextRow = row - 1; diff --git a/web/src/utils/markdown-table.test.ts b/web/src/utils/markdown-table.test.ts index 229ace880..40c541507 100644 --- a/web/src/utils/markdown-table.test.ts +++ b/web/src/utils/markdown-table.test.ts @@ -219,6 +219,13 @@ more text`; expect(tables[0].start).toBe(0); expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); }); + + it("finds a pipe-less GFM table", () => { + const content = "A | B\n--- | ---\n1 | 2"; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); }); // --------------------------------------------------------------------------- diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts index f2099a8bc..2f1babc55 100644 --- a/web/src/utils/markdown-table.ts +++ b/web/src/utils/markdown-table.ts @@ -1,6 +1,10 @@ /** * Utilities for parsing, serializing, and manipulating markdown tables. */ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { visit } from "unist-util-visit"; export interface TableData { headers: string[]; @@ -33,11 +37,11 @@ export function parseMarkdownTable(md: string): TableData | null { if (lines.length < 2) return null; const parseRow = (line: string): string[] => { - // Strip leading/trailing pipes and split by pipe. + // Strip leading/trailing pipes and split by unescaped 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()); + return trimmed.split(/(? cell.trim()); }; const headers = parseRow(lines[0]); @@ -131,9 +135,6 @@ export function serializeMarkdownTable(data: TableData): string { // 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; @@ -145,33 +146,24 @@ export interface TableMatch { /** * Find all markdown table blocks in a content string. + * + * Uses a GFM-aware markdown AST parser so that tables without leading/trailing + * pipes (e.g. `A | B\n--- | ---\n1 | 2`) are recognised in addition to + * fully-fenced `| … |` tables. */ export function findAllTables(content: string): TableMatch[] { - const lines = content.split("\n"); + const tree = fromMarkdown(content, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + 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 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: startOffset + text.length }); - } - } else { - offset += lines[i].length + 1; - i++; - } - } - + visit(tree, "table", (node) => { + if (!node.position) return; + const start = node.position.start.offset ?? 0; + const end = node.position.end.offset ?? content.length; + tables.push({ text: content.slice(start, end), start, end }); + }); return tables; } From 004e667b7ac17f69f2dc1eabec8df0d882e714f1 Mon Sep 17 00:00:00 2001 From: milvasic Date: Mon, 23 Mar 2026 19:35:22 +0100 Subject: [PATCH 07/11] fix(table): escape pipe chars in cells on serialize, unescape on parse - escapeCell() replaces unescaped | with \| before writing each cell, applied in both width calculation and formatRow so padding is accurate - parseRow unescapes \| back to | after splitting on unescaped pipes - Adds round-trip regression test for cells containing pipe characters Addresses coderabbitai review comment on PR #5680. --- web/src/utils/markdown-table.test.ts | 12 ++++++++++++ web/src/utils/markdown-table.ts | 10 ++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/utils/markdown-table.test.ts b/web/src/utils/markdown-table.test.ts index 40c541507..6c0875db8 100644 --- a/web/src/utils/markdown-table.test.ts +++ b/web/src/utils/markdown-table.test.ts @@ -138,6 +138,18 @@ describe("serializeMarkdownTable", () => { expect(new Set(lines.map((l) => l.length)).size).toBe(1); }); + it("round-trips a cell containing a pipe character", () => { + const data: TableData = { + headers: ["A", "B"], + rows: [["foo|bar", "baz"]], + alignments: ["none", "none"], + }; + const md = serializeMarkdownTable(data); + const parsed = parseMarkdownTable(md); + expect(parsed?.rows[0][0]).toBe("foo|bar"); + expect(parsed?.rows[0][1]).toBe("baz"); + }); + it("round-trips through parse and serialize", () => { const original = `| Name | Age | | ----- | --- | diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts index 2f1babc55..7c2567f4c 100644 --- a/web/src/utils/markdown-table.ts +++ b/web/src/utils/markdown-table.ts @@ -41,7 +41,7 @@ export function parseMarkdownTable(md: string): TableData | null { let trimmed = line; if (trimmed.startsWith("|")) trimmed = trimmed.slice(1); if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1); - return trimmed.split(/(? cell.trim()); + return trimmed.split(/(? cell.trim().replace(/\\\|/g, "|")); }; const headers = parseRow(lines[0]); @@ -84,12 +84,14 @@ export function serializeMarkdownTable(data: TableData): string { const { headers, rows, alignments } = data; const colCount = headers.length; + const escapeCell = (text: string): string => text.replace(/(? { const formatted = cells.map((cell, i) => { const align = alignments[i] || "none"; - return padCell(cell, widths[i], align); + return padCell(escapeCell(cell), widths[i], align); }); return "| " + formatted.join(" | ") + " |"; }; From aa0f072c42590fb6c784aef727f38e79fce21eee Mon Sep 17 00:00:00 2001 From: milvasic Date: Mon, 23 Mar 2026 19:40:00 +0100 Subject: [PATCH 08/11] perf(Table): memoize findAllTables result to avoid redundant AST parses Hoist `findAllTables(memo.content)` into a shared `useMemo` so the markdown AST is parsed exactly once per `memo.content` change. Both `resolveTableIndex` and `handleEditClick` now reference the same cached `tables` array, keeping the lookup and edit paths in sync and eliminating the duplicate parse that previously occurred on every edit click. --- web/src/components/MemoContent/Table.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index e6686c14c..9ad1a8684 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import type { TableData } from "@/utils/markdown-table"; import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table"; -import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; +import { useMemoViewContext, useMemoViewDerived } from "@/components/MemoView/MemoViewContext"; import type { ReactMarkdownProps } from "./markdown/types"; // --------------------------------------------------------------------------- @@ -32,24 +32,24 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { const { readonly } = useMemoViewDerived(); const { mutateAsync: updateMemo } = useUpdateMemo(); + const tables = useMemo(() => findAllTables(memo.content), [memo.content]); + /** Resolve which markdown table index this rendered table corresponds to using AST source positions. */ const resolveTableIndex = useMemo(() => { const nodeStart = node?.position?.start?.offset; if (nodeStart == null) return -1; - const tables = findAllTables(memo.content); for (let i = 0; i < tables.length; i++) { if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i; } return -1; - }, [memo.content, node]); + }, [tables, node]); const handleEditClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - const tables = findAllTables(memo.content); if (resolveTableIndex < 0 || resolveTableIndex >= tables.length) return; const parsed = parseMarkdownTable(tables[resolveTableIndex].text); @@ -59,7 +59,7 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { setTableIndex(resolveTableIndex); setDialogOpen(true); }, - [memo.content, resolveTableIndex], + [tables, resolveTableIndex], ); const handleDeleteClick = useCallback( From 26dec86b70c8988e09e3bb2a678498b2dbdca200 Mon Sep 17 00:00:00 2001 From: milvasic Date: Mon, 23 Mar 2026 19:47:39 +0100 Subject: [PATCH 09/11] fix(markdown-table): handle escaped backslashes before pipe separators; use @/ alias in test import - Rewrite parseRow to count consecutive backslashes before each pipe so that `\\|` (escaped backslash + unescaped pipe) is correctly treated as a column separator. The previous lookbehind regex (? { - // Strip leading/trailing pipes and split by unescaped pipe. + // Strip leading/trailing pipes and split by pipes preceded by an even number + // of backslashes (0, 2, 4, …). A pipe preceded by an odd number of + // backslashes is an escaped pipe and must not be treated as a column + // separator. The simpler regex (? cell.trim().replace(/\\\|/g, "|")); + + const cells: string[] = []; + let cellStart = 0; + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i] === "|") { + let backslashes = 0; + let j = i - 1; + while (j >= 0 && trimmed[j] === "\\") { + backslashes++; + j--; + } + if (backslashes % 2 === 0) { + cells.push(trimmed.slice(cellStart, i).trim().replace(/\\\|/g, "|")); + cellStart = i + 1; + } + } + } + cells.push(trimmed.slice(cellStart).trim().replace(/\\\|/g, "|")); + return cells; }; const headers = parseRow(lines[0]); From 0ad53f9dd34c0a6062de8cb8235df98b182444dd Mon Sep 17 00:00:00 2001 From: milvasic Date: Tue, 24 Mar 2026 18:42:47 +0100 Subject: [PATCH 10/11] refactor(table): rename resolveTableIndex, fix escapeCell backslash handling, remove redundant cast - Rename `resolveTableIndex` useMemo value to `currentTableIndex` in Table.tsx so the name reflects a computed value rather than an action; update all references (callbacks, dependency arrays, JSDoc comment) - Fix `escapeCell` in markdown-table.ts: replace the single-char lookbehind regex `(? { const tables = useMemo(() => findAllTables(memo.content), [memo.content]); - /** Resolve which markdown table index this rendered table corresponds to using AST source positions. */ - const resolveTableIndex = useMemo(() => { + /** The index of the markdown table this rendered table corresponds to (from AST source positions). */ + const currentTableIndex = useMemo(() => { const nodeStart = node?.position?.start?.offset; if (nodeStart == null) return -1; @@ -50,16 +50,16 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { e.stopPropagation(); e.preventDefault(); - if (resolveTableIndex < 0 || resolveTableIndex >= tables.length) return; + if (currentTableIndex < 0 || currentTableIndex >= tables.length) return; - const parsed = parseMarkdownTable(tables[resolveTableIndex].text); + const parsed = parseMarkdownTable(tables[currentTableIndex].text); if (!parsed) return; setTableData(parsed); - setTableIndex(resolveTableIndex); + setTableIndex(currentTableIndex); setDialogOpen(true); }, - [tables, resolveTableIndex], + [tables, currentTableIndex], ); const handleDeleteClick = useCallback( @@ -67,12 +67,12 @@ export const Table = ({ children, className, node, ...props }: TableProps) => { e.stopPropagation(); e.preventDefault(); - if (resolveTableIndex < 0) return; + if (currentTableIndex < 0) return; - setTableIndex(resolveTableIndex); + setTableIndex(currentTableIndex); setDeleteDialogOpen(true); }, - [resolveTableIndex], + [currentTableIndex], ); const handleConfirmEdit = useCallback( diff --git a/web/src/utils/markdown-table.test.ts b/web/src/utils/markdown-table.test.ts index c4a145c69..37ab970ca 100644 --- a/web/src/utils/markdown-table.test.ts +++ b/web/src/utils/markdown-table.test.ts @@ -150,6 +150,24 @@ describe("serializeMarkdownTable", () => { expect(parsed?.rows[0][1]).toBe("baz"); }); + it("round-trips a cell whose value has two backslashes before a pipe (\\\\\\\\|)", () => { + // Cell value "foo\\|bar" (two backslashes + pipe) is a valid parsed value + // that comes from markdown "foo\\\\\\|bar". The old escapeCell regex + // (? { const original = `| Name | Age | | ----- | --- | diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts index 1103776c5..4c6a55ab8 100644 --- a/web/src/utils/markdown-table.ts +++ b/web/src/utils/markdown-table.ts @@ -107,7 +107,22 @@ export function serializeMarkdownTable(data: TableData): string { const { headers, rows, alignments } = data; const colCount = headers.length; - const escapeCell = (text: string): string => text.replace(/(? { + let result = ""; + for (let i = 0; i < text.length; i++) { + if (text[i] === "|") { + let backslashes = 0; + let j = i - 1; + while (j >= 0 && text[j] === "\\") { + backslashes++; + j--; + } + if (backslashes % 2 === 0) result += "\\"; + } + result += text[i]; + } + return result; + }; // Calculate maximum width per column (minimum 3 for the separator). const widths: number[] = []; @@ -214,6 +229,6 @@ 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), + alignments: Array.from({ length: cols }, () => "none"), }; } From 324c644071105717c28e1c3479afa39b409392bf Mon Sep 17 00:00:00 2001 From: milvasic Date: Tue, 24 Mar 2026 18:50:32 +0100 Subject: [PATCH 11/11] fix: close table dialog and restore editor focus after confirm --- web/src/components/MemoEditor/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 163d52b60..8151c896d 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -74,6 +74,8 @@ const MemoEditorImpl: React.FC = ({ const handleTableConfirm = useCallback((markdown: string) => { editorRef.current?.insertText(markdown); + setTableDialogOpen(false); + editorRef.current?.focus(); }, []); useKeyboard(editorRef, handleSave);