feat: add Copy Text and Download .docx buttons to response window
- Copy Text: copies raw markdown to clipboard with visual feedback - Download .docx: converts markdown to a properly formatted Word document using the docx library (headings, bold/italic, lists, tables, blockquotes, links, horizontal rules) - Both buttons appear below the response article after streaming ends - docx/file-saver are lazy-loaded (dynamic import) so they don't bloat the initial bundle (~308 KB main vs ~407 KB docx chunk) Co-Authored-By: alex <alex@semipublic.co>
This commit is contained in:
+96
-10
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function App() {
|
||||
const [result, setResult] = useState('')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [streamError, setStreamError] = useState(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const resultRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +31,33 @@ export default function App() {
|
||||
}
|
||||
}, [result])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
/* clipboard API unavailable */
|
||||
}
|
||||
}, [result])
|
||||
|
||||
const handleDownloadDocx = useCallback(async () => {
|
||||
const [{ Packer }, { saveAs }, { markdownToDocx }] = await Promise.all([
|
||||
import('docx'),
|
||||
import('file-saver'),
|
||||
import('./markdownToDocx'),
|
||||
])
|
||||
const title = form.stationName
|
||||
? `Funding Brief — ${form.stationName}`
|
||||
: 'PMC Funding Brief'
|
||||
const doc = markdownToDocx(result, title)
|
||||
const blob = await Packer.toBlob(doc)
|
||||
const filename = form.stationName
|
||||
? `${form.stationName.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}_Funding_Brief.docx`
|
||||
: 'PMC_Funding_Brief.docx'
|
||||
saveAs(blob, filename)
|
||||
}, [result, form.stationName])
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setForm((prev) => ({ ...prev, [name]: value }))
|
||||
@@ -282,15 +310,38 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{(result || isStreaming) && (
|
||||
<article
|
||||
ref={resultRef}
|
||||
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{result}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && <span className="cursor-pulse" />}
|
||||
</article>
|
||||
<div>
|
||||
<article
|
||||
ref={resultRef}
|
||||
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{result}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && <span className="cursor-pulse" />}
|
||||
</article>
|
||||
|
||||
{result && !isStreaming && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadDocx}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Download .docx
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 shadow-sm transition hover:bg-stone-50"
|
||||
>
|
||||
<ClipboardIcon />
|
||||
{copied ? 'Copied!' : 'Copy text'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -344,6 +395,41 @@ function StatusBanner({ children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ClipboardIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Document,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
AlignmentType,
|
||||
ExternalHyperlink,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
BorderStyle,
|
||||
convertInchesToTwip,
|
||||
} from 'docx'
|
||||
|
||||
const HEADING_MAP = {
|
||||
1: HeadingLevel.HEADING_1,
|
||||
2: HeadingLevel.HEADING_2,
|
||||
3: HeadingLevel.HEADING_3,
|
||||
4: HeadingLevel.HEADING_4,
|
||||
}
|
||||
|
||||
/** Parse inline markdown (bold, italic, links, code) into TextRun / Hyperlink children. */
|
||||
function parseInline(text) {
|
||||
const children = []
|
||||
// Match **bold**, *italic*, [link](url), `code`, or plain text.
|
||||
const re =
|
||||
/(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`)/g
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
children.push(new TextRun(text.slice(last, m.index)))
|
||||
}
|
||||
if (m[2]) {
|
||||
// ***bold italic***
|
||||
children.push(new TextRun({ text: m[2], bold: true, italics: true }))
|
||||
} else if (m[3]) {
|
||||
// **bold**
|
||||
children.push(new TextRun({ text: m[3], bold: true }))
|
||||
} else if (m[4]) {
|
||||
// *italic*
|
||||
children.push(new TextRun({ text: m[4], italics: true }))
|
||||
} else if (m[5] && m[6]) {
|
||||
// [text](url)
|
||||
children.push(
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: m[5],
|
||||
style: 'Hyperlink',
|
||||
}),
|
||||
],
|
||||
link: m[6],
|
||||
}),
|
||||
)
|
||||
} else if (m[7]) {
|
||||
// `code`
|
||||
children.push(
|
||||
new TextRun({
|
||||
text: m[7],
|
||||
font: 'Courier New',
|
||||
size: 20,
|
||||
}),
|
||||
)
|
||||
}
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < text.length) {
|
||||
children.push(new TextRun(text.slice(last)))
|
||||
}
|
||||
return children.length ? children : [new TextRun(text)]
|
||||
}
|
||||
|
||||
/** Convert a markdown string to a docx Document. */
|
||||
export function markdownToDocx(markdown, title) {
|
||||
const lines = markdown.split('\n')
|
||||
const children = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Blank line → skip
|
||||
if (!line.trim()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/)
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: HEADING_MAP[level],
|
||||
children: parseInline(headingMatch[2]),
|
||||
}),
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA' },
|
||||
},
|
||||
children: [],
|
||||
}),
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Table — collect rows that start with |
|
||||
if (line.trimStart().startsWith('|')) {
|
||||
const tableLines = []
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||
tableLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
const parsed = parseTable(tableLines)
|
||||
if (parsed) children.push(parsed)
|
||||
continue
|
||||
}
|
||||
|
||||
// Unordered list item
|
||||
const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/)
|
||||
if (ulMatch) {
|
||||
const indent = Math.floor(ulMatch[1].length / 2)
|
||||
children.push(
|
||||
new Paragraph({
|
||||
bullet: { level: indent },
|
||||
children: parseInline(ulMatch[2]),
|
||||
}),
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ordered list item
|
||||
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/)
|
||||
if (olMatch) {
|
||||
const indent = Math.floor(olMatch[1].length / 3)
|
||||
children.push(
|
||||
new Paragraph({
|
||||
numbering: { reference: 'default-numbering', level: indent },
|
||||
children: parseInline(olMatch[2]),
|
||||
}),
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
const bqMatch = line.match(/^>\s?(.*)/)
|
||||
if (bqMatch) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
indent: { left: convertInchesToTwip(0.5) },
|
||||
children: [
|
||||
new TextRun({ text: bqMatch[1], italics: true, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular paragraph — consume consecutive non-blank, non-special lines
|
||||
const paraLines = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!lines[i].match(/^#{1,4}\s/) &&
|
||||
!lines[i].match(/^[-*+]\s/) &&
|
||||
!lines[i].match(/^\d+\.\s/) &&
|
||||
!lines[i].match(/^>\s?/) &&
|
||||
!lines[i].match(/^(-{3,}|\*{3,}|_{3,})\s*$/) &&
|
||||
!lines[i].trimStart().startsWith('|')
|
||||
) {
|
||||
paraLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
if (paraLines.length) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: parseInline(paraLines.join(' ')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return new Document({
|
||||
title: title || 'PMC Funding Brief',
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: 'default-numbering',
|
||||
levels: Array.from({ length: 9 }, (_, lvl) => ({
|
||||
level: lvl,
|
||||
format: 'decimal',
|
||||
text: `%${lvl + 1}.`,
|
||||
alignment: AlignmentType.START,
|
||||
style: { paragraph: { indent: { left: convertInchesToTwip(0.5 * (lvl + 1)), hanging: convertInchesToTwip(0.25) } } },
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
})
|
||||
}
|
||||
|
||||
/** Parse GFM table lines into a docx Table. */
|
||||
function parseTable(tableLines) {
|
||||
// Filter out the separator row (|---|---|)
|
||||
const rows = tableLines.filter(
|
||||
(l) => !l.replace(/[|\-:\s]/g, '').length === false && !/^\|[\s-:|]+\|$/.test(l),
|
||||
)
|
||||
// Re-parse: first non-separator is header, rest are body
|
||||
const dataRows = tableLines.filter((l) => !/^\|[\s\-:]+\|$/.test(l))
|
||||
if (!dataRows.length) return null
|
||||
|
||||
const parseCells = (line) =>
|
||||
line
|
||||
.split('|')
|
||||
.slice(1, -1) // drop leading/trailing empty from split
|
||||
.map((c) => c.trim())
|
||||
|
||||
const headerCells = parseCells(dataRows[0])
|
||||
const bodyRows = dataRows.slice(1).map(parseCells)
|
||||
|
||||
const makeCellParagraph = (text, bold) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: bold
|
||||
? [new TextRun({ text, bold: true })]
|
||||
: parseInline(text),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const docxRows = [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headerCells.map((c) => makeCellParagraph(c, true)),
|
||||
}),
|
||||
...bodyRows.map(
|
||||
(cells) =>
|
||||
new TableRow({
|
||||
children: cells.map((c) => makeCellParagraph(c, false)),
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
return new Table({
|
||||
rows: docxRows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user