mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e20fd97be7
* 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
147 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
};
|