完善编辑功能

This commit is contained in:
2026-07-16 17:59:56 +08:00
parent c5a5a4e55b
commit 164e658e5b
12 changed files with 2299 additions and 86 deletions

802
package-lock.json generated
View File

@@ -8,6 +8,15 @@
"name": "yurou",
"version": "0.1.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "6.4.0",
"@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@lucide/vue": "^1.23.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -18,11 +27,13 @@
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"reka-ui": "^2.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remixicon": "^4.9.1",
"shadcn-vue": "^2.7.4",
"shiki": "^4.3.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.0",
@@ -470,6 +481,407 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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-angular": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
"integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.3"
}
},
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@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-go": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"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.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmmirror.com/@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-jinja": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz",
"integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-less": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
"integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-liquid": {
"version": "6.3.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz",
"integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.4.0",
"resolved": "https://registry.npmmirror.com/@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/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sass": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
"integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/sass": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-vue": {
"version": "0.1.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
"integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-wast": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
"integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmmirror.com/@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/language-data": {
"version": "6.5.2",
"resolved": "https://registry.npmmirror.com/@codemirror/language-data/-/language-data-6.5.2.tgz",
"integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-angular": "^0.1.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-go": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-jinja": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-less": "^6.0.0",
"@codemirror/lang-liquid": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-php": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-sass": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-vue": "^0.1.1",
"@codemirror/lang-wast": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.4.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/view": {
"version": "6.43.6",
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/@dotenvx/dotenvx/-/dotenvx-2.11.1.tgz",
@@ -1260,6 +1672,183 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/cpp": {
"version": "1.1.6",
"resolved": "https://registry.npmmirror.com/@lezer/cpp/-/cpp-1.1.6.tgz",
"integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmmirror.com/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmmirror.com/@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/java": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"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.npmmirror.com/@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/json": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.19",
"resolved": "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz",
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/sass": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@lezer/sass/-/sass-1.1.0.tgz",
"integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@lucide/vue": {
"version": "1.24.0",
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.24.0.tgz",
@@ -1269,6 +1858,12 @@
"vue": ">=3.0.1"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1733,6 +2328,106 @@
"win32"
]
},
"node_modules/@shikijs/core": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/core/-/core-4.3.1.tgz",
"integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.3.1",
"@shikijs/types": "4.3.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz",
"integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.1",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz",
"integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.1",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.3.1.tgz",
"integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.3.1.tgz",
"integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/themes": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.3.1.tgz",
"integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/@shikijs/types/-/types-4.3.1.tgz",
"integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -3374,6 +4069,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/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.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5647,6 +6348,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-newline-to-break": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz",
"integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-find-and-replace": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -6567,6 +7282,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
"integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
"integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.2",
"regex": "^6.1.0",
"regex-recursion": "^6.0.2"
}
},
"node_modules/open": {
"version": "11.0.0",
"resolved": "https://registry.npmmirror.com/open/-/open-11.0.0.tgz",
@@ -7128,6 +7860,30 @@
"node": ">= 4"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
@@ -7182,6 +7938,21 @@
"vue": ">= 3.4.0"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/remark-breaks/-/remark-breaks-4.0.0.tgz",
"integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-newline-to-break": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -7548,6 +8319,25 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/shiki/-/shiki-4.3.1.tgz",
"integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.3.1",
"@shikijs/engine-javascript": "4.3.1",
"@shikijs/engine-oniguruma": "4.3.1",
"@shikijs/langs": "4.3.1",
"@shikijs/themes": "4.3.1",
"@shikijs/types": "4.3.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
@@ -7779,6 +8569,12 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/stylus": {
"version": "0.57.0",
"resolved": "https://registry.npmmirror.com/stylus/-/stylus-0.57.0.tgz",
@@ -8603,6 +9399,12 @@
"typescript": ">=5.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/web-worker/-/web-worker-1.5.0.tgz",

View File

@@ -29,6 +29,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"reka-ui": "^2.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",

20
pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
reka-ui:
specifier: ^2.10.1
version: 2.10.1(vue@3.5.39(typescript@5.6.3))
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -2211,6 +2214,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-newline-to-break@2.0.0:
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -2650,6 +2656,9 @@ packages:
peerDependencies:
vue: '>= 3.4.0'
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -5352,6 +5361,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-newline-to-break@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-find-and-replace: 3.0.2
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -5911,6 +5925,12 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-newline-to-break: 2.0.0
unified: 11.0.0
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4

View File

@@ -8,6 +8,7 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default"

View File

@@ -552,12 +552,37 @@ struct SyncStats {
skipped: usize,
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "camelCase")]
enum SyncFileAction {
Upload,
Download,
DeleteLocal,
DeleteRemote,
Skip,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncFileDetail {
path: String,
action: SyncFileAction,
size: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncResult {
message: String,
last_sync_at: String,
stats: SyncStats,
files: Vec<SyncFileDetail>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncConfigSaveResult {
config_path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -1173,6 +1198,7 @@ async fn sync_workspace(
let remote_files = scan_remote_files(&op).await?;
let mut history = load_sync_history(app, &key)?;
let mut stats = SyncStats::default();
let mut file_details = Vec::new();
let all_paths: BTreeSet<String> = local_files
.keys()
@@ -1199,6 +1225,11 @@ async fn sync_workspace(
)
.await?;
stats.uploaded += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::Upload,
size: local.map(|item| item.size).unwrap_or(0),
});
}
SyncAction::Download => {
let local_path = local_path_for(workspace, &relative);
@@ -1216,6 +1247,11 @@ async fn sync_workspace(
fs::write(&local_path, bytes)
.map_err(|e| format!("无法写入本地文件 {}: {}", local_path.display(), e))?;
stats.downloaded += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::Download,
size: remote.map(|item| item.size).unwrap_or(0),
});
}
SyncAction::DeleteLocal => {
let local_path = local_path_for(workspace, &relative);
@@ -1225,6 +1261,11 @@ async fn sync_workspace(
}
history.files.remove(&relative);
stats.deleted_local += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::DeleteLocal,
size: local.map(|item| item.size).unwrap_or(0),
});
continue;
}
SyncAction::DeleteRemote => {
@@ -1236,10 +1277,23 @@ async fn sync_workspace(
.await?;
history.files.remove(&relative);
stats.deleted_remote += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::DeleteRemote,
size: remote.map(|item| item.size).unwrap_or(0),
});
continue;
}
SyncAction::Skip => {
stats.skipped += 1;
file_details.push(SyncFileDetail {
path: relative.clone(),
action: SyncFileAction::Skip,
size: local
.map(|item| item.size)
.or_else(|| remote.map(|item| item.size))
.unwrap_or(0),
});
}
}
}
@@ -1282,6 +1336,7 @@ async fn sync_workspace(
message: "同步完成".to_string(),
last_sync_at,
stats,
files: file_details,
})
}
@@ -1291,8 +1346,13 @@ fn load_sync_config(app: tauri::AppHandle) -> Result<SyncConfig, String> {
}
#[tauri::command]
fn save_sync_config(app: tauri::AppHandle, config: SyncConfig) -> Result<(), String> {
save_sync_config_to_disk(&app, &config)
fn save_sync_config(
app: tauri::AppHandle,
config: SyncConfig,
) -> Result<SyncConfigSaveResult, String> {
save_sync_config_to_disk(&app, &config)?;
let config_path = sync_config_path(&app)?.to_string_lossy().into_owned();
Ok(SyncConfigSaveResult { config_path })
}
#[tauri::command]
@@ -1430,7 +1490,7 @@ pub fn run() {
let id = event.id().0.as_str();
match id {
"quit" => {
std::process::exit(0);
let _ = app_handle.emit("menu-quit", ());
}
"open" => {
let handle = app_handle.clone();

View File

@@ -2,7 +2,7 @@
import {computed, ref, watch, onMounted, onUnmounted, nextTick} from "vue";
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
import {getCurrentWindow} from "@tauri-apps/api/window";
import {getCurrentWindow, type CloseRequestedEvent} from "@tauri-apps/api/window";
import MarkdownEditor from "./components/MarkdownEditor.vue";
import DirectorySidebar from "./components/DirectorySidebar.vue";
import OutlinePanel from "./components/OutlinePanel.vue";
@@ -17,6 +17,7 @@ type WorkspaceFileType = "markdown" | "image" | "video";
type SyncProvider = "none" | "webdav" | "s3";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
const APP_THEME_KEY = "yurou:app-theme";
const RENDER_SINGLE_LINE_BREAKS_KEY = "yurou:render-single-line-breaks";
const MARKDOWN_EXTENSIONS = new Set(["md"]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
@@ -79,7 +80,10 @@ const mediaPreviewSrc = ref("");
const mediaPreviewError = ref("");
const mediaPreviewKey = ref(0);
const saveStatus = ref("");
const saveNotice = ref("");
const saveNoticeType = ref<"success" | "error">("success");
const hasDraftDocument = ref(false);
let lastSavedContent = "";
// Folder state
interface DirEntry {
@@ -105,6 +109,21 @@ interface WorkspaceWatchErrorPayload {
message: string;
}
function loadRenderSingleLineBreaks() {
try {
return localStorage.getItem(RENDER_SINGLE_LINE_BREAKS_KEY) === "true";
} catch {
return false;
}
}
function saveRenderSingleLineBreaks(value: boolean) {
try {
localStorage.setItem(RENDER_SINGLE_LINE_BREAKS_KEY, String(value));
} catch { /* noop */
}
}
interface SyncConfig {
provider: SyncProvider;
webdav: {
@@ -152,6 +171,9 @@ let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let workspaceRefreshInFlight = false;
let workspaceRefreshQueued = false;
let syncInFlight = false;
let fileOpenRequest = 0;
let saveNoticeTimer: ReturnType<typeof setTimeout> | null = null;
let closeSaveInFlight = false;
// ---- editor zoom (Ctrl+scroll) ----
const ZOOM_MIN = 0.7;
@@ -207,6 +229,7 @@ const showOutline = ref(false);
const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
const appTheme = ref<AppTheme>(loadAppTheme());
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
const showMoreMenu = ref(false);
const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
@@ -230,6 +253,7 @@ type TitlebarDragState = {
const TITLEBAR_DRAG_THRESHOLD = 4;
let titlebarDragState: TitlebarDragState | null = null;
let unlistenWindowResize: (() => void) | null = null;
let unlistenWindowCloseRequested: (() => void) | null = null;
function isInteractiveTitlebarTarget(target: EventTarget | null) {
if (!(target instanceof Element)) return false;
@@ -321,6 +345,26 @@ function openSettings(page: "about" | "editor" | "sync" = "about") {
settingsRequest.value += 1;
}
async function onWindowCloseRequested(event: CloseRequestedEvent) {
if (closeSaveInFlight) {
event.preventDefault();
return;
}
closeSaveInFlight = true;
const shouldShowClosingSave = isCurrentMarkdownDocumentDirty();
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) {
closeSaveInFlight = false;
event.preventDefault();
return;
}
if (shouldShowClosingSave) {
await new Promise((resolve) => setTimeout(resolve, 700));
}
}
function setDocumentMode(mode: DocumentMode) {
documentMode.value = mode;
}
@@ -367,6 +411,11 @@ onMounted(() => {
}).catch((error) => {
console.error("Failed to listen for window resize:", error);
});
void getCurrentWindow().onCloseRequested(onWindowCloseRequested).then((unlisten) => {
unlistenWindowCloseRequested = unlisten;
}).catch((error) => {
console.error("Failed to listen for window close requests:", error);
});
}
void restoreLastWorkspace();
});
@@ -378,7 +427,12 @@ onUnmounted(() => {
unlistenWindowResize();
unlistenWindowResize = null;
}
if (unlistenWindowCloseRequested) {
unlistenWindowCloseRequested();
unlistenWindowCloseRequested = null;
}
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
if (saveNoticeTimer) clearTimeout(saveNoticeTimer);
});
function stripMarkdown(md: string): string {
@@ -422,7 +476,9 @@ function setDocumentContent(md: string) {
}
function resetMarkdownEditorState() {
fileOpenRequest += 1;
setDocumentContent("");
lastSavedContent = "";
currentFilePath.value = null;
currentFileType.value = "markdown";
resetMediaPreviewState();
@@ -601,8 +657,14 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() {
const contentToSave = getLatestEditorContent();
if (!currentFilePath.value && !contentToSave.trim()) return true;
if (contentToSave === lastSavedContent) return true;
return doSave(contentToSave, { syncAfterSave: false });
return doSave(contentToSave, { syncAfterSave: false, trigger: "automatic" });
}
function updateRenderSingleLineBreaks(value: boolean) {
renderSingleLineBreaks.value = value;
saveRenderSingleLineBreaks(value);
}
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
@@ -696,6 +758,7 @@ async function refreshCurrentOpenFileFromDisk() {
if (isMarkdownDocument.value) {
const fileContent = await invoke<string>("read_file", { path: currentFilePath.value });
setDocumentContent(fileContent);
lastSavedContent = fileContent;
hasDraftDocument.value = false;
editorResetKey.value += 1;
return;
@@ -826,6 +889,7 @@ function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "im
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
setDocumentContent("");
lastSavedContent = "";
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
@@ -984,6 +1048,15 @@ async function onRenameEntry(path: string, newName: string) {
}
async function onOpenFile(path: string) {
const request = ++fileOpenRequest;
if (currentFilePath.value && getWorkspaceIdentity(currentFilePath.value) === getWorkspaceIdentity(path)) {
return;
}
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved || request !== fileOpenRequest) return;
const fileType = getWorkspaceFileType(path);
if (fileType === "image" || fileType === "video") {
openMediaPreview(path, fileType);
@@ -992,9 +1065,11 @@ async function onOpenFile(path: string) {
try {
const fileContent = await invoke<string>("read_file", {path});
if (request !== fileOpenRequest) return;
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent(fileContent);
lastSavedContent = fileContent;
currentFilePath.value = path;
hasDraftDocument.value = false;
saveStatus.value = "";
@@ -1009,7 +1084,29 @@ function getLatestEditorContent(contentOverride?: string) {
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
}
async function doSave(contentOverride?: string, options: { syncAfterSave?: boolean } = {}) {
function isCurrentMarkdownDocumentDirty() {
if (!isMarkdownDocument.value) return false;
if (!currentFilePath.value && !hasDraftDocument.value) return false;
const latestContent = getLatestEditorContent();
if (!currentFilePath.value && !latestContent.trim()) return false;
return latestContent !== lastSavedContent;
}
function showSaveNotice(message: string, type: "success" | "error" = "success") {
if (saveNoticeTimer) clearTimeout(saveNoticeTimer);
saveNotice.value = message;
saveNoticeType.value = type;
saveNoticeTimer = setTimeout(() => {
saveNotice.value = "";
saveNoticeTimer = null;
}, 3000);
}
async function doSave(
contentOverride?: string,
options: { syncAfterSave?: boolean; trigger?: "manual" | "automatic" } = {},
) {
if (!isMarkdownDocument.value) return false;
const contentToSave = getLatestEditorContent(contentOverride);
@@ -1018,17 +1115,25 @@ async function doSave(contentOverride?: string, options: { syncAfterSave?: boole
// No file path yet — prompt user to pick a save location
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return false; // user cancelled
if (!chosen) {
showSaveNotice(options.trigger === "automatic" ? "已取消自动保存" : "已取消保存", "error");
return false;
}
currentFilePath.value = chosen;
} catch (e) {
console.error("Save dialog failed:", e);
showSaveNotice(`保存失败: ${e}`, "error");
return false;
}
}
try {
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
const fileName = getFileName(currentFilePath.value);
const message = options.trigger === "automatic" ? `已自动保存: ${fileName}` : `已手动保存: ${fileName}`;
saveStatus.value = message;
showSaveNotice(message);
lastSavedContent = contentToSave;
setTimeout(() => {
saveStatus.value = "";
}, 3000);
@@ -1040,6 +1145,7 @@ async function doSave(contentOverride?: string, options: { syncAfterSave?: boole
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
showSaveNotice(saveStatus.value, "error");
return false;
}
}
@@ -1051,10 +1157,15 @@ async function doSaveAs() {
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return; // user cancelled
if (!chosen) {
showSaveNotice("已取消另存为", "error");
return;
}
currentFilePath.value = chosen;
await invoke("write_file", {path: currentFilePath.value, content: contentToSave});
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
saveStatus.value = `手动保存: ${getFileName(currentFilePath.value)}`;
showSaveNotice(saveStatus.value);
lastSavedContent = contentToSave;
setTimeout(() => {
saveStatus.value = "";
}, 3000);
@@ -1063,13 +1174,19 @@ async function doSaveAs() {
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
showSaveNotice(saveStatus.value, "error");
}
}
function doNew() {
async function doNew() {
fileOpenRequest += 1;
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return;
currentFileType.value = "markdown";
resetMediaPreviewState();
setDocumentContent("");
lastSavedContent = "";
currentFilePath.value = null;
hasDraftDocument.value = true;
saveStatus.value = "";
@@ -1112,7 +1229,11 @@ registerTauriListener("menu-save-as", () => {
});
registerTauriListener("menu-new", () => {
doNew();
void doNew();
});
registerTauriListener("menu-quit", () => {
void closeWindow();
});
registerTauriListener("show-about", () => {
@@ -1147,6 +1268,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:settings-initial-page="settingsInitialPage"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
:recent-workspaces="recentWorkspaces"
@open-folder="onOpenFolder"
@switch-workspace="(path: string) => switchWorkspace(path)"
@@ -1160,6 +1282,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
@search-result-click="handleSearchResultClick"
@update-default-document-mode="updateDefaultDocumentMode"
@update-app-theme="updateAppTheme"
@update-render-single-line-breaks="updateRenderSingleLineBreaks"
@prepare-workspace-sync="prepareWorkspaceSync"
@workspace-synced="handleWorkspaceSynced"
/>
@@ -1307,6 +1430,7 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:model-value="content"
:document-mode="documentMode"
:file-path="currentFilePath"
:render-single-line-breaks="renderSingleLineBreaks"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate"
@save-shortcut="doSave"
@@ -1379,6 +1503,18 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
{{ Math.round(editorZoom * 100) }}%
</div>
</Transition>
<Transition name="save-notice">
<div
v-if="saveNotice"
class="save-notice fixed top-12 right-4 z-50 pointer-events-none select-none"
:class="{ 'save-notice-error': saveNoticeType === 'error' }"
role="status"
aria-live="polite"
>
<i :class="saveNoticeType === 'error' ? 'ri-error-warning-line' : 'ri-checkbox-circle-line'"></i>
<span>{{ saveNotice }}</span>
</div>
</Transition>
</main>
</div>
<!-- <footer class="app-footer backdrop-blur-sm">-->
@@ -1416,6 +1552,17 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
opacity: 0;
}
.save-notice-enter-active,
.save-notice-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.save-notice-enter-from,
.save-notice-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.outline-slide-enter-active,
.outline-slide-leave-active {
transition:
@@ -1465,6 +1612,48 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
color: var(--app-control-contrast);
}
.save-notice {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: min(360px, calc(100vw - 32px));
min-height: 32px;
padding: 7px 11px;
border: 1px solid #86efac;
border-radius: 6px;
background: #f0fdf4;
color: #14532d;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.save-notice i {
flex: 0 0 auto;
color: #15803d;
font-size: 16px;
}
.save-notice span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.save-notice-error {
border-color: #fca5a5;
background: #fef2f2;
color: #7f1d1d;
}
.save-notice-error i {
color: #b91c1c;
}
/* ── Media preview ──────────────────────────────────────────────── */
.media-preview {

View File

@@ -31,6 +31,7 @@ const props = withDefaults(
settingsInitialPage?: SettingsPage;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
recentWorkspaces?: WorkspaceEntry[];
}>(),
{
@@ -39,6 +40,7 @@ const props = withDefaults(
settingsInitialPage: "about",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
recentWorkspaces: () => [],
},
);
@@ -55,6 +57,7 @@ const emit = defineEmits<{
renameEntry: [path: string, newName: string];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
"search-result-click": [filePath: string, lineNumber: number];
@@ -842,8 +845,10 @@ function handleCreateRootFile() {
:workspace-path="folderPath"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
@update-render-single-line-breaks="(value: boolean) => emit('updateRenderSingleLineBreaks', value)"
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
@workspace-synced="emit('workspaceSynced')"
/>

View File

@@ -0,0 +1,476 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
acceptCompletion,
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
pickedCompletion,
startCompletion,
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import {
EditorSelection,
EditorState,
StateEffect,
StateField,
type Text,
} from "@codemirror/state";
import {
crosshairCursor,
Decoration,
type DecorationSet,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
placeholder as placeholderExtension,
rectangularSelection,
} from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { highlightCodeToTokens } from "@/lib/codeHighlight";
import { getMarkdownLanguageCompletions } from "@/lib/markdownLanguages";
import { resolveMarkdownCodeLanguage } from "@/lib/markdownLanguages";
interface PastePayload {
event: ClipboardEvent;
value: string;
from: number;
to: number;
}
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
autofocus?: boolean;
minHeight?: string;
}>(),
{
placeholder: "",
autofocus: false,
minHeight: "1.625em",
},
);
const emit = defineEmits<{
"update:modelValue": [value: string];
blur: [];
escape: [];
paste: [payload: PastePayload];
}>();
const hostRef = ref<HTMLDivElement>();
let editorView: EditorView | null = null;
let highlightRequestId = 0;
const setCodeDecorations = StateEffect.define<DecorationSet>();
const codeDecorationField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, transaction) {
let next = value.map(transaction.changes);
for (const effect of transaction.effects) {
if (effect.is(setCodeDecorations)) next = effect.value;
}
return next;
},
provide: (field) => EditorView.decorations.from(field),
});
interface FencedCodeBlock {
from: number;
to: number;
code: string;
language: string;
}
function findFencedCodeBlocks(doc: Text): FencedCodeBlock[] {
const blocks: FencedCodeBlock[] = [];
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const opener = /^(\s*)(`{3,}|~{3,})\s*([\w.+#-]*)/.exec(line.text);
if (!opener) continue;
const fenceChar = opener[2][0];
const fenceLength = opener[2].length;
const closingPattern = new RegExp(`^\\s*${fenceChar === "`" ? "`" : "~"}{${fenceLength},}\\s*$`);
const contentFrom = Math.min(doc.length, line.to + 1);
for (let closingLineNumber = lineNumber + 1; closingLineNumber <= doc.lines; closingLineNumber += 1) {
const closingLine = doc.line(closingLineNumber);
if (!closingPattern.test(closingLine.text)) continue;
const requestedLanguage = opener[3] || "text";
blocks.push({
from: contentFrom,
to: closingLine.from,
code: doc.sliceString(contentFrom, closingLine.from),
language: resolveMarkdownCodeLanguage(requestedLanguage)?.shiki || "text",
});
lineNumber = closingLineNumber;
break;
}
}
return blocks;
}
async function updateCodeDecorations(view: EditorView) {
const requestId = ++highlightRequestId;
const documentText = view.state.doc.toString();
const blocks = findFencedCodeBlocks(view.state.doc);
const ranges: ReturnType<Decoration["range"]>[] = [];
for (const block of blocks) {
const tokenLines = await highlightCodeToTokens(block.code, block.language);
if (!tokenLines || requestId !== highlightRequestId) return;
for (const tokens of tokenLines) {
for (const token of tokens) {
if (!token.content || !token.color) continue;
const from = block.from + token.offset;
const to = Math.min(block.to, from + token.content.length);
if (to <= from) continue;
ranges.push(
Decoration.mark({
class: "cm-shiki-token",
attributes: { style: `color: ${token.color}` },
}).range(from, to),
);
}
}
}
if (
requestId !== highlightRequestId ||
!editorView ||
editorView !== view ||
view.state.doc.toString() !== documentText
) {
return;
}
view.dispatch({ effects: setCodeDecorations.of(Decoration.set(ranges, true)) });
}
function fenceLanguageCompletion(context: CompletionContext): CompletionResult | null {
const line = context.state.doc.lineAt(context.pos);
const beforeCursor = context.state.sliceDoc(line.from, context.pos);
const match = /^(\s*)(`{3,}|~{3,})([\w.+#-]*)$/.exec(beforeCursor);
if (!match) return null;
return {
from: line.from + match[1].length + match[2].length,
options: getMarkdownLanguageCompletions(),
validFor: /^[\w.+#-]*$/,
};
}
function insertClosedCodeFence(view: EditorView) {
const selection = view.state.selection.main;
if (!selection.empty) return false;
const line = view.state.doc.lineAt(selection.head);
const beforeCursor = view.state.sliceDoc(line.from, selection.head);
const afterCursor = view.state.sliceDoc(selection.head, line.to);
const match = /^(\s*)(`{3,}|~{3,})[\w.+#-]*\s*$/.exec(beforeCursor);
if (!match || afterCursor.trim()) return false;
const nextLine = line.number < view.state.doc.lines
? view.state.doc.line(line.number + 1).text
: "";
const fence = match[2][0].repeat(match[2].length);
if (nextLine.trimStart().startsWith(fence)) return false;
const insert = `\n${match[1]}\n${match[1]}${fence}`;
view.dispatch({
changes: { from: selection.head, insert },
selection: { anchor: selection.head + 1 + match[1].length },
scrollIntoView: true,
userEvent: "input",
});
return true;
}
function acceptCompletionOrCloseFence(view: EditorView) {
return acceptCompletion(view) || insertClosedCodeFence(view);
}
const editorHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, color: "var(--app-text-strong)", fontWeight: "700" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strong, fontWeight: "700" },
{ tag: tags.link, color: "var(--code-token-link)", textDecoration: "underline" },
{ tag: tags.url, color: "var(--code-token-link)" },
{
tag: tags.monospace,
color: "var(--code-token-string)",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
{ tag: [tags.keyword, tags.operatorKeyword], color: "var(--code-token-keyword)" },
{ tag: [tags.string, tags.regexp], color: "var(--code-token-string-expression)" },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--code-token-constant)" },
{ tag: [tags.comment, tags.meta], color: "var(--code-token-comment)" },
{
tag: [tags.function(tags.variableName), tags.labelName],
color: "var(--code-token-function)",
},
{
tag: [tags.typeName, tags.className, tags.namespace, tags.tagName],
color: "var(--code-token-function)",
},
{ tag: tags.propertyName, color: "var(--code-token-constant)" },
{ tag: tags.attributeName, color: "var(--code-token-parameter)" },
{ tag: [tags.punctuation, tags.separator, tags.bracket], color: "var(--code-token-punctuation)" },
{ tag: tags.inserted, color: "var(--code-token-inserted)" },
{ tag: tags.deleted, color: "var(--code-token-deleted)" },
{ tag: tags.changed, color: "var(--code-token-changed)" },
{ tag: tags.invalid, color: "var(--code-token-deleted)" },
]);
const editorTheme = EditorView.theme({
"&": {
color: "var(--code-foreground)",
backgroundColor: "transparent",
fontFamily: "inherit",
fontSize: "inherit",
minHeight: "var(--markdown-code-editor-min-height)",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: "inherit",
lineHeight: "1.625",
overflow: "visible",
},
".cm-content": {
padding: "0",
minHeight: "var(--markdown-code-editor-min-height)",
caretColor: "var(--app-accent)",
},
".cm-line": { padding: "0" },
".cm-shiki-token > span": { color: "inherit !important" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--app-accent)" },
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": {
backgroundColor: "var(--app-selection) !important",
},
".cm-placeholder": { color: "var(--app-subtle)" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-gutters": { display: "none" },
".cm-tooltip": {
border: "1px solid var(--app-border-strong)",
borderRadius: "6px",
backgroundColor: "var(--app-surface)",
color: "var(--app-text)",
boxShadow: "0 10px 28px var(--app-shadow)",
overflow: "hidden",
},
".cm-tooltip-autocomplete > ul": {
fontFamily: '"Geist", "PingFang SC", "Microsoft YaHei", sans-serif',
maxHeight: "240px",
},
".cm-tooltip-autocomplete > ul > li": {
minWidth: "220px",
padding: "6px 10px",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--app-active-bg)",
color: "var(--app-text-strong)",
},
".cm-completionLabel": { fontWeight: "600" },
".cm-completionDetail": { color: "var(--app-muted)", fontStyle: "normal" },
});
function createEditor() {
const host = hostRef.value;
if (!host) return;
editorView = new EditorView({
parent: host,
state: EditorState.create({
doc: props.modelValue,
extensions: [
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSelectionMatches(),
markdown({ codeLanguages: languages }),
syntaxHighlighting(editorHighlightStyle),
codeDecorationField,
closeBrackets(),
autocompletion({ override: [fenceLanguageCompletion], activateOnTyping: true }),
EditorView.lineWrapping,
editorTheme,
placeholderExtension(props.placeholder),
keymap.of([
{ key: "Enter", run: acceptCompletionOrCloseFence },
...completionKeymap,
{
key: "Escape",
run() {
emit("escape");
return true;
},
},
indentWithTab,
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
emit("update:modelValue", update.state.doc.toString());
void updateCodeDecorations(update.view);
}
if (
!update.docChanged ||
update.transactions.some((transaction) => transaction.annotation(pickedCompletion))
) {
return;
}
const selection = update.state.selection.main;
if (!selection.empty) return;
const line = update.state.doc.lineAt(selection.head);
const beforeCursor = update.state.sliceDoc(line.from, selection.head);
if (/^\s*(`{3,}|~{3,})[\w.+#-]*$/.test(beforeCursor)) {
queueMicrotask(() => {
if (editorView) startCompletion(editorView);
});
}
}),
EditorView.domEventHandlers({
blur() {
emit("blur");
return false;
},
paste(event, view) {
const selection = view.state.selection.main;
emit("paste", {
event,
value: view.state.doc.toString(),
from: selection.from,
to: selection.to,
});
return event.defaultPrevented;
},
}),
],
}),
});
if (props.autofocus) {
requestAnimationFrame(() => focusAtEnd());
}
void updateCodeDecorations(editorView);
}
watch(
() => props.modelValue,
(value) => {
const view = editorView;
if (!view || value === view.state.doc.toString()) return;
const cursor = Math.min(view.state.selection.main.head, value.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
selection: EditorSelection.cursor(cursor),
});
},
);
function focus() {
editorView?.focus();
}
function focusAtEnd() {
const view = editorView;
if (!view) return;
const end = view.state.doc.length;
view.dispatch({ selection: EditorSelection.cursor(end), scrollIntoView: true });
view.focus();
}
function replaceRange(text: string, from?: number, to?: number) {
const view = editorView;
if (!view) return;
const selection = view.state.selection.main;
const replaceFrom = from ?? selection.from;
const replaceTo = to ?? selection.to;
const cursor = replaceFrom + text.length;
view.dispatch({
changes: { from: replaceFrom, to: replaceTo, insert: text },
selection: EditorSelection.cursor(cursor),
scrollIntoView: true,
userEvent: "input.paste",
});
view.focus();
}
function scrollToLine(lineNumber: number) {
const view = editorView;
if (!view) return;
const safeLine = Math.max(1, Math.min(lineNumber, view.state.doc.lines));
const line = view.state.doc.line(safeLine);
view.dispatch({ selection: EditorSelection.cursor(line.from), scrollIntoView: true });
view.focus();
}
defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine });
onMounted(createEditor);
onBeforeUnmount(() => {
highlightRequestId += 1;
editorView?.destroy();
editorView = null;
});
</script>
<template>
<div
ref="hostRef"
class="markdown-code-editor"
:style="{ '--markdown-code-editor-min-height': props.minHeight }"
/>
</template>
<style scoped>
.markdown-code-editor {
width: 100%;
min-width: 0;
}
</style>

View File

@@ -9,6 +9,7 @@ import {
import { unified, type Plugin, type Transformer } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -75,6 +76,7 @@ const props = withDefaults(
documentMode?: DocumentMode;
previewOnly?: boolean;
filePath?: string | null;
renderSingleLineBreaks?: boolean;
}>(),
{
modelValue: "",
@@ -82,6 +84,7 @@ const props = withDefaults(
documentMode: undefined,
previewOnly: false,
filePath: null,
renderSingleLineBreaks: false,
},
);
@@ -220,14 +223,23 @@ const mediaSanitizeSchema = {
tagNames: [...(defaultSchema.tagNames || []), "video"],
};
/** Full pipeline: markdown → HTML */
const md2html = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkLocalMedia)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize, mediaSanitizeSchema)
.use(rehypeStringify);
function createMarkdownProcessor(renderSingleLineBreaks: boolean) {
const processor = unified().use(remarkParse).use(remarkGfm);
if (renderSingleLineBreaks) {
processor.use(remarkBreaks);
}
return processor
.use(remarkLocalMedia)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize, mediaSanitizeSchema)
.use(rehypeStringify);
}
/** Full pipelines: standard Markdown and optional single-line breaks. */
const standardMd2html = createMarkdownProcessor(false);
const singleLineBreaksMd2html = createMarkdownProcessor(true);
const mdParser = unified().use(remarkParse).use(remarkGfm);
@@ -350,7 +362,8 @@ async function highlightCodeBlocks(html: string) {
}
async function renderMarkdownHtml(md: string) {
const file = md2html.processSync(md);
const processor = props.renderSingleLineBreaks ? singleLineBreaksMd2html : standardMd2html;
const file = processor.processSync(md);
return highlightCodeBlocks(String(file.value));
}
@@ -469,6 +482,13 @@ watch(
{ immediate: true },
);
watch(
() => props.renderSingleLineBreaks,
() => {
syncRenderedState(props.modelValue);
},
);
// ── Editor handlers ────────────────────────────────────────────────
function focusBlockEditor() {
@@ -978,7 +998,10 @@ defineExpose({
v-for="block in markdownBlocks"
:key="block.id"
class="markdown-block"
:class="{ 'markdown-block-editing': editingBlockId === block.id }"
:class="{
'markdown-block-editing': editingBlockId === block.id,
'markdown-block-paragraph': block.type === 'paragraph',
}"
>
<MarkdownCodeEditor
v-if="editingBlockId === block.id"
@@ -1054,7 +1077,16 @@ defineExpose({
.markdown-preview h3 { font-size: 1.125em; }
.markdown-preview h4 { font-size: 1em; }
.markdown-preview p { margin: 0.5em 0; }
.markdown-preview p { margin: 0; }
.markdown-preview:not(.markdown-mixed) > p + p,
.markdown-mixed
> .markdown-block-paragraph
+ .markdown-block-paragraph
> .markdown-block-preview
> p {
margin-top: 1.625em;
}
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
.markdown-preview em { font-style: italic; }
@@ -1183,6 +1215,12 @@ defineExpose({
display: block;
}
.markdown-mixed
> .markdown-block-paragraph
+ .markdown-block-paragraph.markdown-block-editing {
margin-top: 1.625em;
}
.markdown-block-editing,
.markdown-block-editing:hover {
background-color: transparent;

View File

@@ -41,6 +41,7 @@ interface SyncResult {
deletedRemote: number;
skipped: number;
};
files: SyncFileDetail[];
}
interface SyncConnectionTestResult {
@@ -48,6 +49,27 @@ interface SyncConnectionTestResult {
details: string[];
}
interface SyncConfigSaveResult {
configPath: string;
}
type SyncFileAction = "upload" | "download" | "deleteLocal" | "deleteRemote" | "skip";
interface SyncFileDetail {
path: string;
action: SyncFileAction;
size: number;
}
interface SyncFeedback {
kind: "progress" | "save" | "test" | "sync" | "error";
title: string;
summary: string;
details?: string[];
configPath?: string;
result?: SyncResult;
}
const props = withDefaults(
defineProps<{
show: boolean;
@@ -55,12 +77,14 @@ const props = withDefaults(
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
},
);
@@ -68,6 +92,7 @@ const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
@@ -83,8 +108,7 @@ const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
const updateStatus = ref("");
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
const syncStatus = ref("");
const syncError = ref("");
const syncFeedback = ref<SyncFeedback | null>(null);
const syncSaving = ref(false);
const syncTesting = ref(false);
const syncRunning = ref(false);
@@ -98,6 +122,32 @@ const pageTitle = computed(() => {
});
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
const syncProviderLabel = computed(() => {
if (syncConfig.value.provider === "webdav") return "WebDAV";
if (syncConfig.value.provider === "s3") return "S3 兼容";
return "关闭同步";
});
const syncTargetDescription = computed(() => {
if (syncConfig.value.provider === "webdav") {
const endpoint = syncConfig.value.webdav.endpoint.trim() || "未填写服务器地址";
const remoteDir = syncConfig.value.webdav.remoteDir.trim() || "/";
return `远端目标:${endpoint} · ${remoteDir}`;
}
if (syncConfig.value.provider === "s3") {
const bucket = syncConfig.value.s3.bucket.trim() || "未填写 Bucket";
const prefix = syncConfig.value.s3.remotePrefix.trim() || "/";
return `远端目标Bucket ${bucket} · ${prefix}`;
}
return "同步已关闭";
});
const syncFileDetails = computed(() => {
const files = syncFeedback.value?.result?.files || [];
return [...files].sort((left, right) => {
if (left.action === "skip" && right.action !== "skip") return 1;
if (left.action !== "skip" && right.action === "skip") return -1;
return left.path.localeCompare(right.path);
});
});
const menuItems: Array<{
id: SettingsPage;
@@ -270,33 +320,62 @@ function onDefaultDocumentModeChange(event: Event) {
emit("updateDefaultDocumentMode", mode);
}
function setSyncStatus(message: string, isError = false) {
if (isError) {
syncError.value = message;
syncStatus.value = "";
} else {
syncStatus.value = message;
syncError.value = "";
function setSyncError(message: string, title = "操作失败") {
syncFeedback.value = {
kind: "error",
title,
summary: message,
};
}
function syncActionLabel(action: SyncFileAction) {
if (action === "upload") return "上传";
if (action === "download") return "下载";
if (action === "deleteLocal") return "删除本地";
if (action === "deleteRemote") return "删除远端";
return "未变更";
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async function loadSyncConfig() {
try {
const config = await invoke<SyncConfig>("load_sync_config");
syncConfig.value = mergeSyncConfig(config);
syncError.value = "";
} catch (error) {
setSyncStatus(`读取同步配置失败: ${error}`, true);
setSyncError(`读取同步配置失败: ${error}`, "无法读取同步配置");
}
}
async function saveSyncConfig() {
syncSaving.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在保存配置",
summary: `正在保存 ${syncProviderLabel.value} 配置。`,
};
try {
await invoke("save_sync_config", { config: syncConfig.value });
setSyncStatus("同步配置已保存");
const result = await invoke<SyncConfigSaveResult>("save_sync_config", { config: syncConfig.value });
syncFeedback.value = {
kind: "save",
title: "同步配置已保存",
summary: `已保存 ${syncProviderLabel.value} 配置。`,
configPath: result.configPath,
details: [
syncTargetDescription.value,
`启动时自动同步:${syncConfig.value.autoSyncOnStartup ? "开启" : "关闭"}`,
`保存后自动同步:${syncConfig.value.autoSyncAfterSave ? "开启" : "关闭"}`,
],
};
} catch (error) {
setSyncStatus(`保存失败: ${error}`, true);
setSyncError(String(error), "保存同步配置失败");
} finally {
syncSaving.value = false;
}
@@ -304,36 +383,45 @@ async function saveSyncConfig() {
async function testSyncConnection() {
syncTesting.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在测试连接",
summary: `正在检查 ${syncProviderLabel.value} 服务的读取、写入和删除权限。`,
};
try {
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
setSyncStatus([result.message, ...result.details.map((item) => `- ${item}`)].join("\n"));
syncFeedback.value = {
kind: "test",
title: "连接测试通过",
summary: result.message,
details: result.details,
};
} catch (error) {
setSyncStatus(String(error), true);
setSyncError(String(error), "连接测试失败");
} finally {
syncTesting.value = false;
}
}
function formatSyncResult(result: SyncResult) {
const { stats } = result;
return `同步完成:上传 ${stats.uploaded},下载 ${stats.downloaded},删除本地 ${stats.deletedLocal},删除远端 ${stats.deletedRemote},跳过 ${stats.skipped}`;
}
async function runSyncNow() {
if (!props.workspacePath) {
setSyncStatus("请先打开一个工作目录", true);
setSyncError("请先打开一个工作目录", "无法开始同步");
return;
}
syncRunning.value = true;
try {
setSyncStatus("正在同步:扫描本地和远端文件,远端列表请求最长等待 120 秒。");
syncFeedback.value = {
kind: "progress",
title: "正在同步",
summary: "正在扫描本地和远端文件,远端列表请求最长等待 120 秒。",
};
await invoke("save_sync_config", { config: syncConfig.value });
const prepared = await new Promise<boolean>((resolve) => {
emit("prepareWorkspaceSync", resolve);
});
if (!prepared) {
setSyncStatus("同步已取消,当前文档尚未保存", true);
setSyncError("当前文档尚未保存,同步已取消。", "同步未开始");
return;
}
@@ -342,10 +430,16 @@ async function runSyncNow() {
config: syncConfig.value,
});
syncConfig.value.lastSyncAt = result.lastSyncAt;
setSyncStatus(formatSyncResult(result));
const changedCount = result.stats.uploaded + result.stats.downloaded + result.stats.deletedLocal + result.stats.deletedRemote;
syncFeedback.value = {
kind: "sync",
title: "同步完成",
summary: changedCount > 0 ? `本次同步处理了 ${changedCount} 个变更文件。` : "本地与远端内容已一致,没有文件需要变更。",
result,
};
emit("workspaceSynced");
} catch (error) {
setSyncStatus(`同步失败: ${error}`, true);
setSyncError(String(error), "同步失败");
} finally {
syncRunning.value = false;
}
@@ -357,6 +451,7 @@ watch(
if (!show) return;
activePage.value = props.initialPage;
updateStatus.value = "";
syncFeedback.value = null;
void loadAppVersion();
void loadSyncConfig();
},
@@ -497,6 +592,20 @@ onBeforeUnmount(() => {
</option>
</select>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>单换行显示为换行</h3>
<p>开启后预览和混合模式会保留普通回车产生的换行不修改 Markdown 原文</p>
</div>
<label class="settings-switch" title="单换行显示为换行">
<input
:checked="renderSingleLineBreaks"
type="checkbox"
@change="emit('updateRenderSingleLineBreaks', ($event.target as HTMLInputElement).checked)"
/>
<span></span>
</label>
</div>
</section>
</div>
@@ -669,41 +778,119 @@ onBeforeUnmount(() => {
<p>
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
</p>
<div
v-if="syncStatus || syncError"
class="settings-sync-log"
:class="{ 'settings-sync-log-error': Boolean(syncError) }"
role="status"
>
{{ syncError || syncStatus }}
</div>
</div>
<div class="settings-action-stack">
<div class="settings-config-actions" aria-label="同步配置操作">
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy"
@click="saveSyncConfig"
>
<i :class="syncSaving ? 'ri-loader-4-line settings-spin' : 'ri-save-3-line'"></i>
{{ syncSaving ? '保存中' : '保存配置' }}
</button>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none'"
@click="testSyncConnection"
>
<i :class="syncTesting ? 'ri-loader-4-line settings-spin' : 'ri-pulse-line'"></i>
{{ syncTesting ? '测试中' : '测试连接' }}
</button>
</div>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy"
@click="saveSyncConfig"
>
{{ syncSaving ? '保存中' : '保存配置' }}
</button>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none'"
@click="testSyncConnection"
>
{{ syncTesting ? '测试中' : '测试连接' }}
</button>
<button
class="settings-primary-btn"
class="settings-primary-btn settings-sync-now-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
@click="runSyncNow"
>
<i :class="syncRunning ? 'ri-loader-4-line settings-spin' : 'ri-loop-left-line'"></i>
{{ syncRunning ? '同步中' : '立即同步' }}
</button>
</div>
<section
v-if="syncFeedback"
class="settings-sync-feedback"
:class="{
'settings-sync-feedback-error': syncFeedback.kind === 'error',
'settings-sync-feedback-progress': syncFeedback.kind === 'progress',
}"
role="status"
aria-live="polite"
>
<div class="settings-sync-feedback-header">
<i
:class="
syncFeedback.kind === 'error'
? 'ri-error-warning-line'
: syncFeedback.kind === 'progress'
? 'ri-loader-4-line settings-spin'
: 'ri-checkbox-circle-line'
"
></i>
<div>
<strong>{{ syncFeedback.title }}</strong>
<p>{{ syncFeedback.summary }}</p>
</div>
</div>
<div v-if="syncFeedback.configPath" class="settings-config-path">
<span>配置保存位置</span>
<code :title="syncFeedback.configPath">{{ syncFeedback.configPath }}</code>
</div>
<ul v-if="syncFeedback.details?.length" class="settings-feedback-details">
<li v-for="detail in syncFeedback.details" :key="detail">
<i class="ri-check-line"></i>
<span>{{ detail }}</span>
</li>
</ul>
<template v-if="syncFeedback.result">
<dl class="settings-sync-stats" aria-label="同步数量汇总">
<div>
<dt>上传</dt>
<dd>{{ syncFeedback.result.stats.uploaded }}</dd>
</div>
<div>
<dt>下载</dt>
<dd>{{ syncFeedback.result.stats.downloaded }}</dd>
</div>
<div>
<dt>删除本地</dt>
<dd>{{ syncFeedback.result.stats.deletedLocal }}</dd>
</div>
<div>
<dt>删除远端</dt>
<dd>{{ syncFeedback.result.stats.deletedRemote }}</dd>
</div>
<div>
<dt>未变更</dt>
<dd>{{ syncFeedback.result.stats.skipped }}</dd>
</div>
</dl>
<div class="settings-sync-file-section">
<div class="settings-sync-file-heading">
<strong>文件明细</strong>
<span>{{ syncFileDetails.length }} 个文件</span>
</div>
<div v-if="syncFileDetails.length" class="settings-sync-file-list">
<div v-for="file in syncFileDetails" :key="`${file.action}:${file.path}`" class="settings-sync-file">
<span class="settings-sync-file-action" :data-action="file.action">
{{ syncActionLabel(file.action) }}
</span>
<code :title="file.path">{{ file.path }}</code>
<span class="settings-sync-file-size">{{ formatFileSize(file.size) }}</span>
</div>
</div>
<p v-else class="settings-sync-file-empty">工作目录与远端都没有文件</p>
</div>
</template>
</section>
</div>
</section>
</div>
@@ -948,9 +1135,16 @@ onBeforeUnmount(() => {
.settings-action-stack {
display: flex;
align-items: flex-end;
width: 244px;
align-items: stretch;
flex-direction: column;
gap: 7px;
gap: 8px;
}
.settings-config-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.settings-primary-btn,
@@ -963,6 +1157,7 @@ onBeforeUnmount(() => {
font-size: 13px;
font-weight: 600;
line-height: 1;
gap: 6px;
cursor: pointer;
transition:
background-color 0.15s ease,
@@ -983,7 +1178,7 @@ onBeforeUnmount(() => {
}
.settings-secondary-btn {
min-width: 50px;
min-width: 0;
border: 1px solid var(--app-border-strong);
background: var(--app-surface);
color: var(--app-text);
@@ -1149,28 +1344,224 @@ onBeforeUnmount(() => {
align-items: flex-start;
}
.settings-sync-log {
width: min(100%, 420px);
max-height: 138px;
margin-top: 10px;
overflow: auto;
.settings-sync-now-btn {
width: 100%;
}
.settings-sync-feedback {
display: flex;
min-width: 0;
grid-column: 1 / -1;
flex-direction: column;
gap: 12px;
border: 1px solid color-mix(in srgb, var(--app-accent) 26%, var(--app-border));
border-radius: 6px;
background: color-mix(in srgb, var(--app-accent) 7%, var(--app-surface));
padding: 12px;
}
.settings-sync-feedback-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
}
.settings-sync-feedback-progress {
border-color: var(--app-border-strong);
background: var(--app-surface-muted);
}
.settings-sync-feedback-header {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
align-items: flex-start;
}
.settings-sync-feedback-header > i {
margin-top: 1px;
color: var(--app-accent);
font-size: 16px;
}
.settings-sync-feedback-error .settings-sync-feedback-header > i,
.settings-sync-feedback-error .settings-sync-feedback-header strong {
color: #dc2626;
}
.settings-sync-feedback-header strong,
.settings-sync-file-heading strong {
display: block;
color: var(--app-text-strong);
font-size: 13px;
line-height: 1.35;
}
.settings-sync-feedback-header p,
.settings-sync-file-empty {
margin: 3px 0 0;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1.5;
padding: 9px 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-sync-log-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
.settings-config-path {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
border-top: 1px solid var(--app-border-strong);
padding-top: 10px;
}
.settings-config-path span,
.settings-sync-file-heading span,
.settings-sync-file-size {
color: var(--app-muted);
font-size: 11px;
line-height: 1.35;
}
.settings-config-path code {
min-width: 0;
color: var(--app-text);
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 11px;
overflow-wrap: anywhere;
}
.settings-feedback-details {
display: flex;
margin: 0;
flex-direction: column;
gap: 6px;
padding: 0;
list-style: none;
}
.settings-feedback-details li {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 5px;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1.45;
}
.settings-feedback-details i {
color: var(--app-accent);
}
.settings-sync-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--app-border-strong);
border-bottom: 1px solid var(--app-border-strong);
padding: 9px 0;
}
.settings-sync-stats > div {
min-width: 0;
padding: 0 9px;
border-left: 1px solid var(--app-border-strong);
}
.settings-sync-stats > div:first-child {
border-left: none;
padding-left: 0;
}
.settings-sync-stats dt {
color: var(--app-muted);
font-size: 10px;
line-height: 1.3;
}
.settings-sync-stats dd {
margin: 3px 0 0;
color: var(--app-text-strong);
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.settings-sync-file-section {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.settings-sync-file-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-sync-file-list {
display: flex;
max-height: 220px;
overflow: auto;
flex-direction: column;
border: 1px solid var(--app-border-strong);
border-radius: 5px;
background: color-mix(in srgb, var(--app-surface) 78%, transparent);
}
.settings-sync-file {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 32px;
padding: 6px 9px;
border-top: 1px solid var(--app-border);
}
.settings-sync-file:first-child {
border-top: none;
}
.settings-sync-file-action {
color: var(--app-accent);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.settings-sync-file-action[data-action="deleteLocal"],
.settings-sync-file-action[data-action="deleteRemote"] {
color: #dc2626;
}
.settings-sync-file-action[data-action="skip"] {
color: var(--app-muted);
}
.settings-sync-file code {
min-width: 0;
overflow: hidden;
color: var(--app-text);
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-spin {
animation: settings-spin 0.85s linear infinite;
}
@keyframes settings-spin {
to {
transform: rotate(360deg);
}
}
.theme-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1289,11 +1680,45 @@ onBeforeUnmount(() => {
}
.settings-action-stack {
width: 100%;
align-items: flex-start;
}
.settings-config-actions,
.settings-sync-now-btn {
width: 100%;
}
.settings-config-path {
grid-template-columns: 1fr;
gap: 4px;
}
.settings-sync-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
row-gap: 10px;
}
.settings-sync-stats > div:nth-child(4) {
border-left: none;
}
.settings-sync-file {
grid-template-columns: 58px minmax(0, 1fr);
}
.settings-sync-file-size {
display: none;
}
.settings-switch-group {
min-width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.settings-spin {
animation-duration: 1.8s;
}
}
</style>

100
src/lib/codeHighlight.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { HighlighterCore, LanguageInput, ThemedToken } from "shiki/core";
type LanguageLoader = () => Promise<{ default: LanguageInput }>;
const languageLoaders: Record<string, LanguageLoader> = {
bash: () => import("shiki/langs/bash.mjs"),
c: () => import("shiki/langs/c.mjs"),
cpp: () => import("shiki/langs/cpp.mjs"),
csharp: () => import("shiki/langs/csharp.mjs"),
css: () => import("shiki/langs/css.mjs"),
dockerfile: () => import("shiki/langs/dockerfile.mjs"),
go: () => import("shiki/langs/go.mjs"),
html: () => import("shiki/langs/html.mjs"),
java: () => import("shiki/langs/java.mjs"),
javascript: () => import("shiki/langs/javascript.mjs"),
json: () => import("shiki/langs/json.mjs"),
jsx: () => import("shiki/langs/jsx.mjs"),
kotlin: () => import("shiki/langs/kotlin.mjs"),
markdown: () => import("shiki/langs/markdown.mjs"),
php: () => import("shiki/langs/php.mjs"),
powershell: () => import("shiki/langs/powershell.mjs"),
python: () => import("shiki/langs/python.mjs"),
ruby: () => import("shiki/langs/ruby.mjs"),
rust: () => import("shiki/langs/rust.mjs"),
scss: () => import("shiki/langs/scss.mjs"),
sql: () => import("shiki/langs/sql.mjs"),
swift: () => import("shiki/langs/swift.mjs"),
toml: () => import("shiki/langs/toml.mjs"),
tsx: () => import("shiki/langs/tsx.mjs"),
typescript: () => import("shiki/langs/typescript.mjs"),
vue: () => import("shiki/langs/vue.mjs"),
xml: () => import("shiki/langs/xml.mjs"),
yaml: () => import("shiki/langs/yaml.mjs"),
};
let highlighterPromise: Promise<HighlighterCore> | null = null;
const languagePromises = new Map<string, Promise<void>>();
async function getCodeHighlighter() {
if (!highlighterPromise) {
highlighterPromise = Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
]).then(([core, engine]) =>
core.createHighlighterCore({
themes: [
core.createCssVariablesTheme({
name: "yurou-code",
variablePrefix: "--code-",
}),
],
langs: [],
engine: engine.createJavaScriptRegexEngine(),
}),
);
}
return highlighterPromise;
}
async function loadCodeLanguage(highlighter: HighlighterCore, language: string) {
if (highlighter.getLoadedLanguages().includes(language)) return;
let promise = languagePromises.get(language);
if (!promise) {
const loader = languageLoaders[language];
if (!loader) return;
promise = loader().then((module) => highlighter.loadLanguage(module.default));
languagePromises.set(language, promise);
}
await promise;
}
export async function highlightCodeToHtml(code: string, language: string) {
const highlighter = await getCodeHighlighter();
await loadCodeLanguage(highlighter, language);
if (!highlighter.getLoadedLanguages().includes(language)) return null;
return highlighter.codeToHtml(code, {
lang: language,
theme: "yurou-code",
});
}
export async function highlightCodeToTokens(
code: string,
language: string,
): Promise<ThemedToken[][] | null> {
const highlighter = await getCodeHighlighter();
await loadCodeLanguage(highlighter, language);
if (!highlighter.getLoadedLanguages().includes(language)) return null;
return highlighter.codeToTokens(code, {
lang: language,
theme: "yurou-code",
}).tokens;
}

View File

@@ -0,0 +1,96 @@
import { pickedCompletion, type Completion } from "@codemirror/autocomplete";
export interface MarkdownCodeLanguage {
label: string;
name: string;
aliases: string[];
shiki: string;
}
export const markdownCodeLanguages: MarkdownCodeLanguage[] = [
{ label: "text", name: "Plain text", aliases: ["txt", "plaintext"], shiki: "text" },
{ label: "javascript", name: "JavaScript", aliases: ["js", "node"], shiki: "javascript" },
{ label: "typescript", name: "TypeScript", aliases: ["ts"], shiki: "typescript" },
{ label: "jsx", name: "JSX", aliases: [], shiki: "jsx" },
{ label: "tsx", name: "TSX", aliases: [], shiki: "tsx" },
{ label: "vue", name: "Vue", aliases: ["vuejs"], shiki: "vue" },
{ label: "html", name: "HTML", aliases: ["htm"], shiki: "html" },
{ label: "css", name: "CSS", aliases: [], shiki: "css" },
{ label: "scss", name: "SCSS", aliases: ["sass"], shiki: "scss" },
{ label: "json", name: "JSON", aliases: ["jsonc"], shiki: "json" },
{ label: "yaml", name: "YAML", aliases: ["yml"], shiki: "yaml" },
{ label: "markdown", name: "Markdown", aliases: ["md"], shiki: "markdown" },
{ label: "python", name: "Python", aliases: ["py"], shiki: "python" },
{ label: "java", name: "Java", aliases: [], shiki: "java" },
{ label: "kotlin", name: "Kotlin", aliases: ["kt"], shiki: "kotlin" },
{ label: "c", name: "C", aliases: [], shiki: "c" },
{ label: "cpp", name: "C++", aliases: ["c++"], shiki: "cpp" },
{ label: "csharp", name: "C#", aliases: ["cs", "c#"], shiki: "csharp" },
{ label: "go", name: "Go", aliases: ["golang"], shiki: "go" },
{ label: "rust", name: "Rust", aliases: ["rs"], shiki: "rust" },
{ label: "php", name: "PHP", aliases: [], shiki: "php" },
{ label: "ruby", name: "Ruby", aliases: ["rb"], shiki: "ruby" },
{ label: "swift", name: "Swift", aliases: [], shiki: "swift" },
{ label: "sql", name: "SQL", aliases: [], shiki: "sql" },
{ label: "bash", name: "Bash", aliases: ["sh", "shell", "zsh"], shiki: "bash" },
{ label: "powershell", name: "PowerShell", aliases: ["ps1", "pwsh"], shiki: "powershell" },
{ label: "dockerfile", name: "Dockerfile", aliases: ["docker"], shiki: "dockerfile" },
{ label: "xml", name: "XML", aliases: [], shiki: "xml" },
{ label: "toml", name: "TOML", aliases: [], shiki: "toml" },
];
const languageLookup = new Map<string, MarkdownCodeLanguage>();
for (const language of markdownCodeLanguages) {
languageLookup.set(language.label, language);
for (const alias of language.aliases) {
languageLookup.set(alias, language);
}
}
export function resolveMarkdownCodeLanguage(value: string | null | undefined) {
const normalized = value?.trim().toLowerCase().split(/\s+/, 1)[0] || "";
return languageLookup.get(normalized) ?? null;
}
const RECENT_LANGUAGES_KEY = "markdown-recent-code-languages";
function readRecentLanguages() {
try {
const stored = localStorage.getItem(RECENT_LANGUAGES_KEY);
const value = stored ? JSON.parse(stored) : [];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string").slice(0, 5)
: [];
} catch {
return [];
}
}
export function rememberMarkdownCodeLanguage(label: string) {
const next = [label, ...readRecentLanguages().filter((item) => item !== label)].slice(0, 5);
try {
localStorage.setItem(RECENT_LANGUAGES_KEY, JSON.stringify(next));
} catch {
// Completion remains available when storage is disabled.
}
}
export function getMarkdownLanguageCompletions(): Completion[] {
const recent = readRecentLanguages();
return markdownCodeLanguages.map((language, index) => ({
label: language.label,
displayLabel: language.name,
detail: language.aliases.length > 0 ? language.aliases.join(", ") : language.label,
type: "keyword",
boost: recent.includes(language.label) ? 100 - recent.indexOf(language.label) : -index,
apply(view, completion, from, to) {
rememberMarkdownCodeLanguage(language.label);
view.dispatch({
changes: { from, to, insert: language.label },
annotations: pickedCompletion.of(completion),
});
},
}));
}