feat: publish Regex Tools 0.1.0
This commit is contained in:
223
src/components/ExtractionTree.tsx
Normal file
223
src/components/ExtractionTree.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ExtractionNode } from "../regex/model/match";
|
||||
import { handleTreeNavigation } from "./tree-navigation";
|
||||
|
||||
const MAXIMUM_RENDERED_EXTRACTION_NODES = 2_000;
|
||||
const MAXIMUM_VALUE_PREVIEW_UTF16 = 100;
|
||||
|
||||
export interface ExtractionTreeProps {
|
||||
readonly nodes: readonly ExtractionNode[];
|
||||
readonly selectedId?: string;
|
||||
readonly onSelect: (node: ExtractionNode) => void;
|
||||
readonly repeatedCaptureNumbers: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
function printable(node: ExtractionNode): string {
|
||||
const value = node.value;
|
||||
if (value === undefined) return "—";
|
||||
if (value === "") return "∅ empty";
|
||||
const visible = value
|
||||
.slice(0, MAXIMUM_VALUE_PREVIEW_UTF16)
|
||||
.replaceAll("\n", "↵")
|
||||
.replaceAll("\r", "␍")
|
||||
.replaceAll("\t", "⇥");
|
||||
const exactLength = node.range
|
||||
? node.range.endUtf16 - node.range.startUtf16
|
||||
: undefined;
|
||||
const uiTruncated = value.length > MAXIMUM_VALUE_PREVIEW_UTF16;
|
||||
if (node.status === "truncated") {
|
||||
return `${visible}${uiTruncated ? "…" : ""} (engine value prefix: ${value.length.toLocaleString()} of ${exactLength?.toLocaleString() ?? "unknown"} UTF-16 units${uiTruncated ? "; UI shows the first 100" : ""})`;
|
||||
}
|
||||
return uiTruncated
|
||||
? `${visible}… (${(exactLength ?? value.length).toLocaleString()} UTF-16 units total; UI preview)`
|
||||
: visible;
|
||||
}
|
||||
|
||||
function countNodes(nodes: readonly ExtractionNode[]): number {
|
||||
let count = 0;
|
||||
const pending = [...nodes];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
count += 1;
|
||||
pending.push(...node.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function boundedNodes(
|
||||
nodes: readonly ExtractionNode[],
|
||||
maximum: number,
|
||||
): readonly ExtractionNode[] {
|
||||
let remaining = maximum;
|
||||
const takeNode = (node: ExtractionNode): ExtractionNode | undefined => {
|
||||
if (remaining <= 0) return undefined;
|
||||
remaining -= 1;
|
||||
const children: ExtractionNode[] = [];
|
||||
for (const child of node.children) {
|
||||
const visible = takeNode(child);
|
||||
if (!visible) break;
|
||||
children.push(visible);
|
||||
}
|
||||
return { ...node, children };
|
||||
};
|
||||
const visible: ExtractionNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const bounded = takeNode(node);
|
||||
if (!bounded) break;
|
||||
visible.push(bounded);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function containsNode(nodes: readonly ExtractionNode[], id: string): boolean {
|
||||
const pending = [...nodes];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
if (node.id === id) return true;
|
||||
pending.push(...node.children);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ExtractionTreeNode({
|
||||
node,
|
||||
selectedId,
|
||||
onSelect,
|
||||
repeatedCaptureNumbers,
|
||||
depth,
|
||||
focusableId,
|
||||
}: {
|
||||
readonly node: ExtractionNode;
|
||||
readonly selectedId?: string;
|
||||
readonly onSelect: (node: ExtractionNode) => void;
|
||||
readonly repeatedCaptureNumbers: ReadonlySet<number>;
|
||||
readonly depth: number;
|
||||
readonly focusableId: string;
|
||||
}) {
|
||||
const repeatedFinal =
|
||||
node.groupNumber !== undefined &&
|
||||
repeatedCaptureNumbers.has(node.groupNumber);
|
||||
return (
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-level={depth}
|
||||
aria-selected={node.id === selectedId}
|
||||
tabIndex={node.id === focusableId ? 0 : -1}
|
||||
className={`tree-row extraction-row ${
|
||||
node.id === selectedId ? "is-selected" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(node)}
|
||||
onKeyDown={handleTreeNavigation}
|
||||
>
|
||||
<span className="tree-token">{node.label}</span>
|
||||
<span className="tree-explanation">{printable(node)}</span>
|
||||
<span className={`status status-${node.status}`}>
|
||||
{node.status.replaceAll("-", " ")}
|
||||
</span>
|
||||
<span className="tree-details extraction-details">
|
||||
<span>
|
||||
UTF-16 range:{" "}
|
||||
{node.range
|
||||
? `${node.range.startUtf16}…${node.range.endUtf16}`
|
||||
: "unavailable"}
|
||||
</span>
|
||||
<span>
|
||||
native range:{" "}
|
||||
{node.nativeRange
|
||||
? `${node.nativeRange.start}…${node.nativeRange.end} ${node.nativeRange.unit}`
|
||||
: "unavailable"}
|
||||
</span>
|
||||
<span>provenance: {node.provenance}</span>
|
||||
</span>
|
||||
</button>
|
||||
{repeatedFinal ? (
|
||||
<p className="tree-note">
|
||||
This engine exposes only the final retained capture for this repeated
|
||||
group.
|
||||
</p>
|
||||
) : null}
|
||||
{node.children.length > 0 ? (
|
||||
<ul role="group">
|
||||
{node.children.map((child) => (
|
||||
<ExtractionTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
repeatedCaptureNumbers={repeatedCaptureNumbers}
|
||||
depth={depth + 1}
|
||||
focusableId={focusableId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExtractionTree({
|
||||
nodes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
repeatedCaptureNumbers,
|
||||
}: ExtractionTreeProps) {
|
||||
const totalNodes = useMemo(() => countNodes(nodes), [nodes]);
|
||||
const visibleNodes = useMemo(
|
||||
() => boundedNodes(nodes, MAXIMUM_RENDERED_EXTRACTION_NODES),
|
||||
[nodes],
|
||||
);
|
||||
const renderedNodes = Math.min(totalNodes, MAXIMUM_RENDERED_EXTRACTION_NODES);
|
||||
const selectedNodeVisible =
|
||||
selectedId === undefined || containsNode(visibleNodes, selectedId);
|
||||
const focusableId =
|
||||
selectedId && selectedNodeVisible
|
||||
? selectedId
|
||||
: (visibleNodes[0]?.id ?? "");
|
||||
return (
|
||||
<section className="panel tree-panel" aria-labelledby="extraction-heading">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Engine results</p>
|
||||
<h2 id="extraction-heading">Extraction tree</h2>
|
||||
</div>
|
||||
<span className="provenance-badge is-engine">Actual engine result</span>
|
||||
</header>
|
||||
{totalNodes > renderedNodes ? (
|
||||
<p
|
||||
className="render-limit-banner"
|
||||
role="status"
|
||||
data-testid="extraction-render-limit"
|
||||
>
|
||||
<strong>Tree preview limited.</strong> Showing the first{" "}
|
||||
{renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
|
||||
returned result nodes. All returned data remains available to the
|
||||
other result views and exports.
|
||||
{!selectedNodeVisible
|
||||
? " The selected result is outside this bounded tree preview."
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
{nodes.length > 0 ? (
|
||||
<ul className="tree" role="tree" aria-label="Match extraction">
|
||||
{visibleNodes.map((node) => (
|
||||
<ExtractionTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
repeatedCaptureNumbers={repeatedCaptureNumbers}
|
||||
depth={1}
|
||||
focusableId={focusableId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="empty-state">No matches to extract.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user