Files
backnotprop__plannotator/packages/ui/components/blocks/TableBlock.tsx
Michael Ramos e20fd97be7 Consumer enablement for @plannotator/ui (0.24.0) (#1017)
* feat(ui): AnnotationPanel renderCardFooter + readOnly host props

Per-card footer slot for host reply/resolve UI (clicks inside don't
select the card); readOnly hides delete/edit on all card kinds.
Both optional, both no-op by default — Plannotator unchanged.

* feat(ui): bless 6 exports into the strict-consumer surface

TableOfContents, ResizeHandle, useResizablePanel, useActiveSection,
useScrollViewport, utils/annotationHelpers — verified strict-clean and
backend-free (useResizablePanel persists via the storageBackend seam);
added to the gate and the HANDOFF supported-imports table.

* feat(ui): Viewer allowImages + readOnly host props

allowImages threads to both CommentPopover sites (popover already gated
its attach affordance; Viewer never exposed the knob). readOnly
suppresses every composer entry point — selection toolbar (highlighter
'enabled'), pinpoint, global comment, attachments, checkbox toggles —
while existing annotations still render and select.
Both default to today's behavior.

* fix(ui): lazy-import Viewer/CommentPopover in the consumer test (DOM-less bun test crashed on web-highlighter)

* feat(ui): strict-consumer gate gains verbatimModuleSyntax + noUnusedLocals/Parameters

Fixed the 24 violations the flags surfaced across the supported-import
graph: type-only imports (verbatimModuleSyntax) and dead imports/locals.
Consumers with stricter tsconfigs no longer have to relax them.

* feat(ui): opt-in content-verifying annotation restore

verifyRestoredContent on useAnnotationHighlighter: after a meta-based
fromStore restore, the painted text is checked against originalText
(whitespace-normalized). Mismatch -> highlight removed, text-search
fallback re-anchors; if that fails too, onRestoreMismatch(annotation,
restoredText) fires and nothing is painted. Default off — today's
trust-the-positions behavior. Workspaces hit this live after document
drift; correctness upgrade for every consumer.

* chore(ui): 0.24.0 — HANDOFF consumer-enablement notes + version bump
2026-07-07 19:09:52 -07:00

147 lines
5.1 KiB
TypeScript

import React, { useRef } from 'react';
import type { Block } from '../../types';
import { InlineMarkdown } from '../InlineMarkdown';
interface TableBlockProps {
block: Block;
onHover?: (element: HTMLElement) => void;
onLeave?: () => void;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
}
// Parse pipe-delimited markdown table content into headers + rows.
// Exported so TableToolbar can reuse it to build a CSV copy without
// needing a second parser.
export const parseTableContent = (content: string): { headers: string[]; rows: string[][] } => {
const lines = content.split('\n').filter((line) => line.trim());
if (lines.length === 0) return { headers: [], rows: [] };
const parseRow = (line: string): string[] =>
line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split(/(?<!\\)\|/)
.map((cell) => cell.trim().replace(/\\\|/g, '|'));
const headers = parseRow(lines[0]);
const rows: string[][] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (/^[\|\-:\s]+$/.test(line)) continue; // separator row
rows.push(parseRow(line));
}
return { headers, rows };
};
// RFC 4180 CSV escaping: quote values that contain a comma, double-quote,
// CR, or LF; escape inner double-quotes by doubling them.
const csvEscape = (value: string): string => {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
// Build RFC 4180 CSV from pre-parsed headers + rows. The popout uses this
// directly with TanStack's visible (filter + sort applied) rows.
export const buildCsvFromRows = (headers: string[], rows: string[][]): string => {
const lines = [headers, ...rows].map((row) => row.map(csvEscape).join(','));
return lines.join('\n');
};
// Rebuild a pipe-delimited markdown table from pre-parsed headers + rows.
// Inverse of parseTableContent. Whitespace normalized (one space per cell
// padding). The popout uses this for copy-as-markdown when filter or sort
// might change what the user sees.
// parseTableContent unescapes `\|` → `|`, so cells may hold literal pipes.
// Re-escape on the way out — otherwise the serialized table sprouts extra
// columns wherever a cell had a pipe.
const mdCellEscape = (value: string): string => value.replace(/\|/g, '\\|');
export const buildMarkdownTable = (headers: string[], rows: string[][]): string => {
const headerLine = `| ${headers.map(mdCellEscape).join(' | ')} |`;
const separator = `| ${headers.map(() => '---').join(' | ')} |`;
const bodyLines = rows.map((row) => `| ${row.map(mdCellEscape).join(' | ')} |`);
return [headerLine, separator, ...bodyLines].join('\n');
};
// Convert the markdown table's pipe-delimited source into RFC 4180 CSV.
// The hover toolbar uses this on the raw block content (no filter state).
export const buildCsv = (markdown: string): string => {
const { headers, rows } = parseTableContent(markdown);
return buildCsvFromRows(headers, rows);
};
export const TableBlock: React.FC<TableBlockProps> = ({
block,
onHover,
onLeave,
onOpenLinkedDoc,
onOpenCodeFile,
onNavigateAnchor,
imageBaseDir,
onImageClick,
githubRepo,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const { headers, rows } = parseTableContent(block.content);
const handleMouseEnter = () => {
if (containerRef.current && onHover) onHover(containerRef.current);
};
return (
<div
ref={containerRef}
className="my-4 overflow-x-auto"
data-block-id={block.id}
onMouseEnter={handleMouseEnter}
onMouseLeave={onLeave}
>
<table className="min-w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
{headers.map((header, i) => (
<th key={i} className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30">
<InlineMarkdown
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
text={header}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b border-border/50 hover:bg-muted/20">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-2 text-foreground/80">
<InlineMarkdown
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
text={cell}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};