Files
legal-ai/web-ui/src/components/precedents/formatted-citation.tsx
Chaim c4046cc0a0
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
ui(precedents): citation action buttons icon-only
Drop the visible "העתק" / "ערוך" labels and keep just the icon —
matches the editorial/judicial restraint of the surrounding card.
Tooltip + aria-label preserve the affordance for hover and assistive
tech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:33:55 +00:00

117 lines
3.4 KiB
TypeScript

"use client";
// Rendering helpers for the formal Israeli citation ("כללי הציטוט האחיד").
//
// Backend stores the citation as Markdown: parties' names wrapped in
// **double asterisks**, everything else regular. These helpers:
// 1. Render the citation with <strong> for the bold ranges.
// 2. Copy it to the clipboard as BOTH text/html (so Word/Docs paste
// with bold preserved) and text/plain (which keeps the markers
// so the markdown survives a plain-text paste).
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { toast } from "sonner";
function parseSegments(md: string): Array<{ bold: boolean; text: string }> {
const out: Array<{ bold: boolean; text: string }> = [];
const re = /\*\*([^*]+)\*\*/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(md)) !== null) {
if (m.index > last) out.push({ bold: false, text: md.slice(last, m.index) });
out.push({ bold: true, text: m[1] });
last = re.lastIndex;
}
if (last < md.length) out.push({ bold: false, text: md.slice(last) });
return out;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function FormattedCitation({
citation,
className,
}: {
citation: string;
className?: string;
}) {
const segments = parseSegments(citation);
return (
<span className={className} dir="rtl">
{segments.map((s, i) =>
s.bold ? (
<strong key={i} className="font-semibold">
{s.text}
</strong>
) : (
<span key={i}>{s.text}</span>
),
)}
</span>
);
}
export function CitationCopyButton({
citation,
size = "sm",
}: {
citation: string;
size?: "sm" | "xs";
}) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
const segments = parseSegments(citation);
const html = segments
.map((s) =>
s.bold
? `<strong>${escapeHtml(s.text)}</strong>`
: escapeHtml(s.text),
)
.join("");
const wrappedHtml = `<span dir="rtl">${html}</span>`;
try {
const cb = navigator.clipboard;
if (typeof ClipboardItem !== "undefined" && cb && "write" in cb) {
const item = new ClipboardItem({
"text/html": new Blob([wrappedHtml], { type: "text/html" }),
"text/plain": new Blob([citation], { type: "text/plain" }),
});
await cb.write([item]);
} else {
await cb.writeText(citation);
}
setCopied(true);
toast.success("המראה מקום הועתק (עם הדגשה לצדדים)");
window.setTimeout(() => setCopied(false), 1800);
} catch (err) {
console.error("citation copy failed", err);
toast.error("העתקה נכשלה");
}
}
const dims = size === "xs" ? "h-7 w-7" : "h-8 w-8";
return (
<button
type="button"
onClick={handleCopy}
title={copied ? "הועתק" : "העתק לפי כללי הציטוט (עם הדגשה לצדדים)"}
aria-label={copied ? "הועתק" : "העתק מראה מקום"}
className={`inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy ${dims}`}
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
);
}