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:
Devin AI
2026-05-15 13:30:38 +00:00
parent d8e8fb1d3f
commit 94d4ba90d3
4 changed files with 557 additions and 11 deletions
+96 -10
View File
@@ -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
+265
View File
@@ -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 },
})
}