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:
Generated
+194
-1
@@ -8,6 +8,8 @@
|
||||
"name": "pmc-funder-tool",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"docx": "^9.6.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
@@ -1952,6 +1954,15 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -2526,6 +2537,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2654,6 +2671,41 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
||||
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
|
||||
@@ -2882,6 +2934,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -3011,6 +3069,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
@@ -3097,6 +3165,12 @@
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -3107,6 +3181,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -3257,6 +3337,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3340,6 +3426,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -3350,6 +3448,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4305,6 +4412,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
@@ -4527,6 +4640,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -4809,6 +4928,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -4912,6 +5037,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -5113,6 +5253,21 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -5132,6 +5287,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -5195,6 +5356,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -5505,6 +5675,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
|
||||
@@ -5647,7 +5823,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
@@ -5782,6 +5957,24 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"docx": "^9.6.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
|
||||
+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