import React, { useMemo, useState } from 'react'; import { createColumnHelper, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, type ColumnDef, type SortingState, } from '@tanstack/react-table'; import type { Block } from '../../types'; import { copyTextToClipboard } from '../../utils/clipboard'; import { InlineMarkdown } from '../InlineMarkdown'; import { PopoutDialog } from '../PopoutDialog'; import { parseTableContent, buildCsvFromRows, buildMarkdownTable } from './TableBlock'; interface TablePopoutProps { block: Block; open: boolean; onClose: () => void; /** Portal target — pass Viewer's annotation containerRef so annotation * hooks can walk into the popout's text nodes. Null falls back to body. */ container?: HTMLElement | null; onOpenLinkedDoc?: (path: string) => void; onOpenCodeFile?: (path: string) => void; onNavigateAnchor?: (hash: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; githubRepo?: string; repoHost?: string; } type Row = Record; const TablePopoutImpl: React.FC = ({ block, open, onClose, container, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, repoHost, }) => { const { headers, rows } = useMemo(() => parseTableContent(block.content), [block.content]); const columnIds = useMemo(() => { const seen = new Map(); return headers.map((h, i) => { const base = h.trim() || `col-${i}`; const n = (seen.get(base) ?? 0) + 1; seen.set(base, n); return n === 1 ? base : `${base}-${n}`; }); }, [headers]); const data = useMemo( () => rows.map((row) => { const obj: Row = {}; columnIds.forEach((id, i) => { obj[id] = row[i] ?? ''; }); return obj; }), [rows, columnIds], ); const columns = useMemo[]>(() => { const helper = createColumnHelper(); return columnIds.map((id, i) => helper.accessor((row) => row[id], { id, header: headers[i], cell: (info) => ( ), }), ); }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo, repoHost]); const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [copiedMd, setCopiedMd] = useState(false); const [copiedCsv, setCopiedCsv] = useState(false); const getVisibleRowsData = (): string[][] => table.getRowModel().rows.map((row) => columnIds.map((id) => row.getValue(id) ?? ''), ); const handleCopyMarkdown = async () => { if (await copyTextToClipboard(buildMarkdownTable(headers, getVisibleRowsData()))) { setCopiedMd(true); setTimeout(() => setCopiedMd(false), 1500); } else { console.error('Failed to copy'); } }; const handleCopyCsv = async () => { if (await copyTextToClipboard(buildCsvFromRows(headers, getVisibleRowsData()))) { setCopiedCsv(true); setTimeout(() => setCopiedCsv(false), 1500); } else { console.error('Failed to copy'); } }; const table = useReactTable({ data, columns, state: { sorting, globalFilter }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), globalFilterFn: 'includesString', }); const visibleRows = table.getRowModel().rows; const totalRows = data.length; return (
setGlobalFilter(e.target.value)} placeholder="Filter rows…" className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50" />
{visibleRows.length === totalRows ? `${totalRows} row${totalRows === 1 ? '' : 's'}` : `${visibleRows.length} of ${totalRows}`}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const sort = header.column.getIsSorted(); return ( ); })} ))} {visibleRows.length === 0 ? ( ) : ( visibleRows.map((row) => ( {row.getVisibleCells().map((cell) => ( ))} )) )}
{flexRender(header.column.columnDef.header, header.getContext())}
No rows match the filter.
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); }; // Memoize on meaningful props (block identity, content, open, container). // Upstream Viewer re-renders (annotation toolbar opening, selection change, // hover state shuffling) keep firing while the popout is mounted. Without // this memo, TanStack's flexRender re-evaluates every cell on every parent // re-render, which conflicts with web-highlighter's DOM mutations (the // library inserts tags into the live DOM) and React's reconciler // throws NotFoundError trying to remove nodes it doesn't own. export const TablePopout = React.memo( TablePopoutImpl, (prev, next) => prev.block.id === next.block.id && prev.block.content === next.block.content && prev.open === next.open && prev.container === next.container && prev.imageBaseDir === next.imageBaseDir && prev.githubRepo === next.githubRepo && prev.repoHost === next.repoHost, ); const SortIndicator: React.FC<{ dir: false | 'asc' | 'desc' }> = ({ dir }) => { const activeUp = dir === 'asc'; const activeDown = dir === 'desc'; return ( ▲ ▼ ); };