fix: add image preview and replace Monaco with CodeMirror 6

Markdown and image previews were rendering blank because
@monaco-editor/react loads its core assets from cdn.jsdelivr.net at
runtime, which the deployment server's network can't reach. Switching
to @uiw/react-codemirror removes the CDN dependency entirely (all
assets ship in the build) and cuts bundle size from ~2.5MB to ~770KB.

Also add proper image/* content-type detection so images render via
<img> instead of being force-decoded as editor text.
This commit is contained in:
Tianyang 2026-08-01 18:12:19 +08:00
parent 542361c63a
commit 954b9bede4
8 changed files with 436 additions and 107 deletions

View File

@ -49,11 +49,23 @@ def _extension_of(key: str) -> str:
return "." + name.rsplit(".", 1)[-1].lower()
_IMAGE_CONTENT_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
def _guess_content_type(key: str) -> str:
if key.endswith(".md"):
return "text/markdown"
if key.endswith(".html") or key.endswith(".htm"):
return "text/html"
extension = _extension_of(key)
if extension in _IMAGE_CONTENT_TYPES:
return _IMAGE_CONTENT_TYPES[extension]
return "application/octet-stream"
@ -150,7 +162,7 @@ class DocumentService:
failed = 0
for key in keys:
content_type = _guess_content_type(key)
if content_type == "application/octet-stream":
if content_type not in ("text/markdown", "text/html"):
continue
try:
data = self._file_client.get_object(key)

View File

@ -8,7 +8,9 @@
"name": "dochub-frontend",
"version": "0.1.0",
"dependencies": {
"@monaco-editor/react": "4.6.0",
"@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-markdown": "^6.4.0",
"@uiw/react-codemirror": "^4.25.11",
"react": "18.3.1",
"react-dom": "18.3.1"
},
@ -305,7 +307,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -359,6 +360,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.10",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.10.tgz",
"integrity": "sha512-h/SceTVsN5r+WE+TVP2g3KDvNoSzbSrtZXCKo4vkKdbfT5t4otuVgngGdFukOO/rwRD2++pCxoh6xD4TEVMkQA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.4.0.tgz",
"integrity": "sha512-ZeArR54seh4laFbUTVy0ZmQgO+C/cxxlW4jEoQMhL3HALScBpZBeZcLzrQmJsTEx4is9GzOe0bFAke2B1KZqeA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
"integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -915,29 +1069,79 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.4.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
@ -1468,13 +1672,58 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.11",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
"integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==",
"license": "MIT",
"optional": true,
"peer": true
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.11",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz",
"integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.11",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.3.1",
@ -1806,6 +2055,21 @@
"node": ">= 16"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1846,6 +2110,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1973,16 +2243,6 @@
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2550,19 +2810,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -2626,17 +2873,6 @@
"node": ">=4"
}
},
"node_modules/monaco-editor": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
"integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.4.8",
"marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3076,12 +3312,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@ -3115,6 +3345,12 @@
"node": ">=8"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -3415,6 +3651,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@ -10,7 +10,9 @@
"test": "vitest run"
},
"dependencies": {
"@monaco-editor/react": "4.6.0",
"@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-markdown": "^6.4.0",
"@uiw/react-codemirror": "^4.25.11",
"react": "18.3.1",
"react-dom": "18.3.1"
},

View File

@ -14,6 +14,7 @@ export interface DocumentContent {
key: string;
content: string;
contentType: string;
encoding: 'utf-8' | 'base64';
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';

View File

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { useEffect, useMemo, useState } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html';
import { deleteDocument, getDocument, saveDocument } from '../api/client';
interface DocumentEditorProps {
@ -19,6 +21,12 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const isImage = contentType.startsWith('image/');
const extensions = useMemo(
() => [contentType === 'text/markdown' ? markdown() : html()],
[contentType],
);
useEffect(() => {
setLoading(true);
setLoadError(null);
@ -42,8 +50,8 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
function handleChange(value: string | undefined) {
setContent(value ?? '');
function handleChange(value: string) {
setContent(value);
}
function handleSave() {
@ -115,19 +123,28 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
{deleting ? '删除中…' : '删除'}
</button>
{!isImage && (
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
</div>
</div>
{isImage ? (
<div className="image-preview-area" data-testid="image-preview">
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
</div>
) : (
<div className="editor-area">
<Editor
<CodeMirror
value={content}
onChange={handleChange}
language={contentType === 'text/markdown' ? 'markdown' : 'html'}
options={{ automaticLayout: true }}
extensions={extensions}
height="100%"
style={{ height: '100%', flex: 1 }}
/>
</div>
)}
</div>
);
}

View File

@ -427,8 +427,31 @@ body {
background: var(--paper);
}
.editor-area > section {
.editor-area > div {
flex: 1;
min-width: 0;
}
.editor-area .cm-editor {
height: 100%;
}
.image-preview-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
background: var(--paper);
padding: 24px;
}
.image-preview-area img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
box-shadow: var(--shadow);
border-radius: var(--radius);
}
/* ---------- Search overlay ---------- */

View File

@ -7,16 +7,19 @@ import { listDocuments, getDocument, search, uploadDocument } from '../src/api/c
vi.mock('../src/api/client');
vi.mock('@monaco-editor/react', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
vi.mock('@uiw/react-codemirror', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
data-testid="monaco-editor-mock"
data-testid="codemirror-mock"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
}));
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
beforeEach(() => {
vi.mocked(listDocuments).mockReset();
vi.mocked(getDocument).mockReset();
@ -27,6 +30,7 @@ beforeEach(() => {
key: '',
content: '',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(search).mockResolvedValue({ results: [] });
});
@ -61,6 +65,7 @@ test('selecting a file from the tree loads it into the editor', async () => {
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const user = userEvent.setup();
@ -69,7 +74,7 @@ test('selecting a file from the tree loads it into the editor', async () => {
const fileRow = await screen.findByText('架构设计.md');
await user.click(fileRow);
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
expect(textarea).toHaveValue('# 架构设计');
});
@ -82,6 +87,7 @@ test('searching shows the results overlay, and clicking a result opens the docum
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const user = userEvent.setup();
@ -102,7 +108,7 @@ test('searching shows the results overlay, and clicking a result opens the docum
await waitFor(() => {
expect(screen.queryByTestId('search-results')).not.toBeInTheDocument();
});
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
expect(textarea).toHaveValue('# 架构设计');
});
@ -115,6 +121,7 @@ test('jumping to a search result clears the search input so it does not show sta
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const user = userEvent.setup();
@ -165,7 +172,7 @@ test('switching files while the current document is dirty prompts for confirmati
],
});
vi.mocked(getDocument).mockImplementation((key: string) =>
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown', encoding: 'utf-8' as const })
);
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
@ -173,13 +180,13 @@ test('switching files while the current document is dirty prompts for confirmati
render(<App />);
await user.click(await screen.findByText('文档A.md'));
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
await user.type(textarea, '修改内容');
await user.click(screen.getByText('文档B.md'));
expect(confirmSpy).toHaveBeenCalled();
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容');
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档A.md修改内容');
});
test('confirming the discard prompt proceeds to switch files', async () => {
@ -191,7 +198,7 @@ test('confirming the discard prompt proceeds to switch files', async () => {
],
});
vi.mocked(getDocument).mockImplementation((key: string) =>
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown', encoding: 'utf-8' as const })
);
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
@ -199,13 +206,13 @@ test('confirming the discard prompt proceeds to switch files', async () => {
render(<App />);
await user.click(await screen.findByText('文档A.md'));
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
await user.type(textarea, '修改内容');
await user.click(screen.getByText('文档B.md'));
await waitFor(async () => {
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档B.md');
});
});

View File

@ -1,51 +1,71 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor';
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
vi.mock('../../src/api/client');
vi.mock('@monaco-editor/react', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
vi.mock('@uiw/react-codemirror', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
data-testid="monaco-editor-mock"
data-testid="codemirror-mock"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
}));
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
beforeEach(() => {
vi.mocked(getDocument).mockReset();
vi.mocked(saveDocument).mockReset();
vi.mocked(deleteDocument).mockReset();
});
test('shows an image preview instead of the code editor for image content types', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/图片.png',
content: 'iVBORw0KGgo=',
contentType: 'image/png',
encoding: 'base64',
});
render(<DocumentEditor documentKey="产品文档/图片.png" onDeleted={vi.fn()} />);
const preview = await screen.findByTestId('image-preview');
const img = preview.querySelector('img');
expect(img).toHaveAttribute('src', 'data:image/png;base64,iVBORw0KGgo=');
expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument();
});
test('shows a loading state, then loads and displays document content', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
expect(textarea).toHaveValue('# 架构设计');
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
test('typing marks the document dirty and saving clears the dirty state', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(saveDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
@ -55,7 +75,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
await user.clear(textarea);
await user.type(textarea, '# 新内容');
@ -77,6 +97,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
vi.mocked(saveDocument).mockResolvedValueOnce({
@ -87,7 +108,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
await user.clear(textarea);
await user.type(textarea, '# 未保存的修改');
@ -98,7 +119,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
expect(screen.getByTestId('save-error')).toBeInTheDocument();
});
expect(screen.getByTestId('document-editor')).toBeInTheDocument();
expect(screen.getByTestId('monaco-editor-mock')).toHaveValue('# 未保存的修改');
expect(screen.getByTestId('codemirror-mock')).toHaveValue('# 未保存的修改');
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
await user.click(saveButton);
@ -114,13 +135,14 @@ test('calls onDirtyChange when dirty state changes', async () => {
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const onDirtyChange = vi.fn();
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
const textarea = await screen.findByTestId('codemirror-mock');
await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
@ -138,6 +160,7 @@ test('clicking delete confirms, calls deleteDocument, and calls onDeleted on suc
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(deleteDocument).mockResolvedValue(undefined);
const onDeleted = vi.fn();
@ -146,7 +169,7 @@ test('clicking delete confirms, calls deleteDocument, and calls onDeleted on suc
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
await screen.findByTestId('codemirror-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
@ -164,6 +187,7 @@ test('clicking delete does nothing if the confirmation is declined', async () =>
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const onDeleted = vi.fn();
const user = userEvent.setup();
@ -171,7 +195,7 @@ test('clicking delete does nothing if the confirmation is declined', async () =>
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
await screen.findByTestId('codemirror-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
@ -184,6 +208,7 @@ test('shows an inline error and does not call onDeleted when delete fails', asyn
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
const onDeleted = vi.fn();
@ -192,7 +217,7 @@ test('shows an inline error and does not call onDeleted when delete fails', asyn
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
await screen.findByTestId('codemirror-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);