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:
parent
542361c63a
commit
954b9bede4
@ -49,11 +49,23 @@ def _extension_of(key: str) -> str:
|
|||||||
return "." + name.rsplit(".", 1)[-1].lower()
|
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:
|
def _guess_content_type(key: str) -> str:
|
||||||
if key.endswith(".md"):
|
if key.endswith(".md"):
|
||||||
return "text/markdown"
|
return "text/markdown"
|
||||||
if key.endswith(".html") or key.endswith(".htm"):
|
if key.endswith(".html") or key.endswith(".htm"):
|
||||||
return "text/html"
|
return "text/html"
|
||||||
|
extension = _extension_of(key)
|
||||||
|
if extension in _IMAGE_CONTENT_TYPES:
|
||||||
|
return _IMAGE_CONTENT_TYPES[extension]
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
@ -150,7 +162,7 @@ class DocumentService:
|
|||||||
failed = 0
|
failed = 0
|
||||||
for key in keys:
|
for key in keys:
|
||||||
content_type = _guess_content_type(key)
|
content_type = _guess_content_type(key)
|
||||||
if content_type == "application/octet-stream":
|
if content_type not in ("text/markdown", "text/html"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
data = self._file_client.get_object(key)
|
data = self._file_client.get_object(key)
|
||||||
|
|||||||
368
frontend/package-lock.json
generated
368
frontend/package-lock.json
generated
@ -8,7 +8,9 @@
|
|||||||
"name": "dochub-frontend",
|
"name": "dochub-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
@ -305,7 +307,6 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@ -359,6 +360,159 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
@ -915,29 +1069,79 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@monaco-editor/loader": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.7.0",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "4.6.0",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/loader": "^1.4.0"
|
"@lezer/common": "^1.3.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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.3",
|
"version": "4.62.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
|
||||||
@ -1468,13 +1672,58 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@uiw/codemirror-extensions-basic-setup": {
|
||||||
"version": "2.0.7",
|
"version": "4.25.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"dependencies": {
|
||||||
"peer": true
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
@ -1806,6 +2055,21 @@
|
|||||||
"node": ">= 16"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -1846,6 +2110,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -1973,16 +2243,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -2550,19 +2810,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -2626,17 +2873,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@ -3076,12 +3312,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@ -3115,6 +3345,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"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": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
@ -10,7 +10,9 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface DocumentContent {
|
|||||||
key: string;
|
key: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
encoding: 'utf-8' | 'base64';
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Editor from '@monaco-editor/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';
|
import { deleteDocument, getDocument, saveDocument } from '../api/client';
|
||||||
|
|
||||||
interface DocumentEditorProps {
|
interface DocumentEditorProps {
|
||||||
@ -19,6 +21,12 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [deleteError, setDeleteError] = 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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
@ -42,8 +50,8 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
|||||||
onDirtyChange?.(isDirty);
|
onDirtyChange?.(isDirty);
|
||||||
}, [isDirty, onDirtyChange]);
|
}, [isDirty, onDirtyChange]);
|
||||||
|
|
||||||
function handleChange(value: string | undefined) {
|
function handleChange(value: string) {
|
||||||
setContent(value ?? '');
|
setContent(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@ -115,19 +123,28 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
|||||||
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||||
{deleting ? '删除中…' : '删除'}
|
{deleting ? '删除中…' : '删除'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
{!isImage && (
|
||||||
{saving ? '保存中…' : '保存'}
|
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
</button>
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-area">
|
{isImage ? (
|
||||||
<Editor
|
<div className="image-preview-area" data-testid="image-preview">
|
||||||
value={content}
|
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
|
||||||
onChange={handleChange}
|
</div>
|
||||||
language={contentType === 'text/markdown' ? 'markdown' : 'html'}
|
) : (
|
||||||
options={{ automaticLayout: true }}
|
<div className="editor-area">
|
||||||
/>
|
<CodeMirror
|
||||||
</div>
|
value={content}
|
||||||
|
onChange={handleChange}
|
||||||
|
extensions={extensions}
|
||||||
|
height="100%"
|
||||||
|
style={{ height: '100%', flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -427,8 +427,31 @@ body {
|
|||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-area > section {
|
.editor-area > div {
|
||||||
flex: 1;
|
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 ---------- */
|
/* ---------- Search overlay ---------- */
|
||||||
|
|||||||
@ -7,16 +7,19 @@ import { listDocuments, getDocument, search, uploadDocument } from '../src/api/c
|
|||||||
|
|
||||||
vi.mock('../src/api/client');
|
vi.mock('../src/api/client');
|
||||||
|
|
||||||
vi.mock('@monaco-editor/react', () => ({
|
vi.mock('@uiw/react-codemirror', () => ({
|
||||||
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
|
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||||
<textarea
|
<textarea
|
||||||
data-testid="monaco-editor-mock"
|
data-testid="codemirror-mock"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
|
||||||
|
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(listDocuments).mockReset();
|
vi.mocked(listDocuments).mockReset();
|
||||||
vi.mocked(getDocument).mockReset();
|
vi.mocked(getDocument).mockReset();
|
||||||
@ -27,6 +30,7 @@ beforeEach(() => {
|
|||||||
key: '',
|
key: '',
|
||||||
content: '',
|
content: '',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||||
});
|
});
|
||||||
@ -61,6 +65,7 @@ test('selecting a file from the tree loads it into the editor', async () => {
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
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');
|
const fileRow = await screen.findByText('架构设计.md');
|
||||||
await user.click(fileRow);
|
await user.click(fileRow);
|
||||||
|
|
||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('codemirror-mock');
|
||||||
expect(textarea).toHaveValue('# 架构设计');
|
expect(textarea).toHaveValue('# 架构设计');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,6 +87,7 @@ test('searching shows the results overlay, and clicking a result opens the docum
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@ -102,7 +108,7 @@ test('searching shows the results overlay, and clicking a result opens the docum
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId('search-results')).not.toBeInTheDocument();
|
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('# 架构设计');
|
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',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
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) =>
|
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 user = userEvent.setup();
|
||||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
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 />);
|
render(<App />);
|
||||||
|
|
||||||
await user.click(await screen.findByText('文档A.md'));
|
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.type(textarea, '修改内容');
|
||||||
|
|
||||||
await user.click(screen.getByText('文档B.md'));
|
await user.click(screen.getByText('文档B.md'));
|
||||||
|
|
||||||
expect(confirmSpy).toHaveBeenCalled();
|
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 () => {
|
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) =>
|
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 user = userEvent.setup();
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
@ -199,13 +206,13 @@ test('confirming the discard prompt proceeds to switch files', async () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await user.click(await screen.findByText('文档A.md'));
|
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.type(textarea, '修改内容');
|
||||||
|
|
||||||
await user.click(screen.getByText('文档B.md'));
|
await user.click(screen.getByText('文档B.md'));
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
|
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档B.md');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 { vi, test, expect, beforeEach } from 'vitest';
|
||||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||||
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
||||||
|
|
||||||
vi.mock('../../src/api/client');
|
vi.mock('../../src/api/client');
|
||||||
|
|
||||||
vi.mock('@monaco-editor/react', () => ({
|
vi.mock('@uiw/react-codemirror', () => ({
|
||||||
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
|
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||||
<textarea
|
<textarea
|
||||||
data-testid="monaco-editor-mock"
|
data-testid="codemirror-mock"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
|
||||||
|
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDocument).mockReset();
|
vi.mocked(getDocument).mockReset();
|
||||||
vi.mocked(saveDocument).mockReset();
|
vi.mocked(saveDocument).mockReset();
|
||||||
vi.mocked(deleteDocument).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 () => {
|
test('shows a loading state, then loads and displays document content', async () => {
|
||||||
vi.mocked(getDocument).mockResolvedValue({
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
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(textarea).toHaveValue('# 架构设计');
|
||||||
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
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 () => {
|
test('typing marks the document dirty and saving clears the dirty state', async () => {
|
||||||
vi.mocked(getDocument).mockResolvedValue({
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
vi.mocked(saveDocument).mockResolvedValue({
|
vi.mocked(saveDocument).mockResolvedValue({
|
||||||
key: '产品文档/架构设计.md',
|
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()} />);
|
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.clear(textarea);
|
||||||
await user.type(textarea, '# 新内容');
|
await user.type(textarea, '# 新内容');
|
||||||
|
|
||||||
@ -77,6 +97,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
|
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
|
||||||
vi.mocked(saveDocument).mockResolvedValueOnce({
|
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()} />);
|
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.clear(textarea);
|
||||||
await user.type(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('save-error')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('document-editor')).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('未保存');
|
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
|
||||||
|
|
||||||
await user.click(saveButton);
|
await user.click(saveButton);
|
||||||
@ -114,13 +135,14 @@ test('calls onDirtyChange when dirty state changes', async () => {
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const onDirtyChange = vi.fn();
|
const onDirtyChange = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||||
@ -138,6 +160,7 @@ test('clicking delete confirms, calls deleteDocument, and calls onDeleted on suc
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
||||||
const onDeleted = vi.fn();
|
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} />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
await screen.findByTestId('monaco-editor-mock');
|
await screen.findByTestId('codemirror-mock');
|
||||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
await user.click(deleteButton);
|
await user.click(deleteButton);
|
||||||
|
|
||||||
@ -164,6 +187,7 @@ test('clicking delete does nothing if the confirmation is declined', async () =>
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
const onDeleted = vi.fn();
|
const onDeleted = vi.fn();
|
||||||
const user = userEvent.setup();
|
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} />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
await screen.findByTestId('monaco-editor-mock');
|
await screen.findByTestId('codemirror-mock');
|
||||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
await user.click(deleteButton);
|
await user.click(deleteButton);
|
||||||
|
|
||||||
@ -184,6 +208,7 @@ test('shows an inline error and does not call onDeleted when delete fails', asyn
|
|||||||
key: '产品文档/架构设计.md',
|
key: '产品文档/架构设计.md',
|
||||||
content: '# 架构设计',
|
content: '# 架构设计',
|
||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
||||||
const onDeleted = vi.fn();
|
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} />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
await screen.findByTestId('monaco-editor-mock');
|
await screen.findByTestId('codemirror-mock');
|
||||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
await user.click(deleteButton);
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user