Compare commits

..

8 Commits

Author SHA1 Message Date
81a0484654 完善预览功能 2026-07-23 00:48:48 +08:00
63d1f0b729 完善功能 2026-07-21 18:49:30 +08:00
abd9f2912f 完善功能 2026-07-20 18:10:56 +08:00
0f75a3a6af 完善功能 2026-07-20 18:10:08 +08:00
05898a14e0 完善编辑功能 2026-07-20 17:47:24 +08:00
f725261370 完善预览功能 2026-07-19 23:50:02 +08:00
3c6b142098 添加预览图片功能 2026-07-19 19:15:22 +08:00
164e658e5b 完善编辑功能 2026-07-16 17:59:56 +08:00
77 changed files with 2766 additions and 294 deletions

View File

@@ -58,5 +58,23 @@
"userTurns": 4,
"basisHash": "aaae120b1f33c2f2",
"updatedAt": 1783674217586
},
"topic_20260710-090003_60717bd90da5ec68": {
"stage": 3,
"userTurns": 3,
"basisHash": "de6e56c00454cedd",
"updatedAt": 1784540365461
},
"topic_20260720-095943_8dd2c8de3f143692": {
"stage": 1,
"userTurns": 1,
"basisHash": "b2acebe8a17318f4",
"updatedAt": 1784541735771
},
"topic_20260722-163413_8ebd34b08091d260": {
"stage": 1,
"userTurns": 1,
"basisHash": "c61a582d55782664",
"updatedAt": 1784738151003
}
}

View File

@@ -1,14 +1,5 @@
{
"topic_20260630-144952_fd0584fe8f266d6a": 1782830992641,
"topic_20260703-022850_50330b4eb93f116d": 1783045730616,
"topic_20260706-145507_076a32df5f4276b5": 1783349707873,
"topic_20260706-153317_238f65a7a470703c": 1783351997029,
"topic_20260710-024855_5c4b1833149d66bb": 1783651735446,
"topic_20260710-030936_cedad2907c3954fe": 1783652976310,
"topic_20260710-032907_600adc6b00791607": 1783654147796,
"topic_20260710-034033_6432ee6566f7b432": 1783654833341,
"topic_20260710-034724_3c6fb29d8d0a4119": 1783655244536,
"topic_20260710-054543_5e7c427932aecd7b": 1783662343716,
"topic_20260710-063053_353d579222d3b70b": 1783665053391,
"topic_20260710-090003_60717bd90da5ec68": 1783674003004
"topic_20260707-131941_481a70d2be9881dd": 1783430381000,
"topic_20260720-095943_8dd2c8de3f143692": 1784541583642,
"topic_20260722-163413_8ebd34b08091d260": 1784738053267
}

View File

@@ -38,6 +38,7 @@
"topic_20260707-093602_5c75f77654cab0c3": "manual",
"topic_20260707-094645_05255aaa45d5f673": "manual",
"topic_20260707-095503_bf879b65e4666516": "auto",
"topic_20260707-131941_481a70d2be9881dd": "manual",
"topic_20260708-020616_9ab54d98daf65053": "auto",
"topic_20260708-093419_71d1fea2e4b5a40f": "auto",
"topic_20260708-095428_990617acd4168b40": "auto",
@@ -48,5 +49,7 @@
"topic_20260710-034724_3c6fb29d8d0a4119": "auto",
"topic_20260710-054543_5e7c427932aecd7b": "auto",
"topic_20260710-063053_353d579222d3b70b": "auto",
"topic_20260710-090003_60717bd90da5ec68": "auto"
"topic_20260710-090003_60717bd90da5ec68": "auto",
"topic_20260720-095943_8dd2c8de3f143692": "auto",
"topic_20260722-163413_8ebd34b08091d260": "auto"
}

View File

@@ -38,6 +38,8 @@
"topic_20260707-093602_5c75f77654cab0c3": "隐藏单栏和双栏的切换功能把mark…",
"topic_20260707-094645_05255aaa45d5f673": "帮我移除markdownEditor…",
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
"topic_20260707-131941_481a70d2be9881dd": "新的会话",
"topic_20260707-132824_49b096d569ffa3d3": "现在帮我把这个markdownEdi…",
"topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…",
"topic_20260708-093419_71d1fea2e4b5a40f": "src/components/Dir…",
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
@@ -48,5 +50,7 @@
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
"topic_20260710-063053_353d579222d3b70b": "这个应用里有一个全局搜索的功能,帮我…",
"topic_20260710-090003_60717bd90da5ec68": "新的会话"
"topic_20260710-090003_60717bd90da5ec68": "D:\\code\\yurou\\src…",
"topic_20260720-095943_8dd2c8de3f143692": "帮我调整一下项目的logo用D…",
"topic_20260722-163413_8ebd34b08091d260": "markdownEditor底部区域…"
}

View File

@@ -5,7 +5,7 @@ This template should help get you started developing with Vue 3 and TypeScript i
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
$env:HTTPS_PROXY="http://127.0.0.1:10808"
hello

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/yurou-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Editor</title>
</head>

953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@fancyapps/ui": "^6.1.14",
"@lezer/highlight": "^1.2.3",
"@lucide/vue": "^1.23.0",
"@tauri-apps/api": "^2",
@@ -29,6 +30,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",

43
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
'@codemirror/view':
specifier: ^6.43.6
version: 6.43.6
'@fancyapps/ui':
specifier: ^6.1.14
version: 6.1.14
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
@@ -68,6 +71,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
@@ -540,6 +546,9 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@fancyapps/ui@6.1.14':
resolution: {integrity: sha512-cBV73XXHrc0qMca+PJJ6qay3xweIgsoYse13rGRstgUs0xA5WAv1tgbSzPCIOOjiLs2S2jiVFJxyszgvgyfxMg==}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -1838,6 +1847,7 @@ packages:
glob@11.0.0:
resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.0:
@@ -2211,6 +2221,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 +2663,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==}
@@ -2804,10 +2820,6 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
string-width@4.2.0:
resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
engines: {node: '>=8'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3689,6 +3701,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@fancyapps/ui@6.1.14': {}
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -3740,7 +3754,7 @@ snapshots:
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.0
string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
@@ -5352,6 +5366,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 +5930,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
@@ -6166,12 +6191,6 @@ snapshots:
stdin-discarder@0.3.2: {}
string-width@4.2.0:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -6450,7 +6469,7 @@ snapshots:
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:

BIN
public/yurou-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

53
src-tauri/nsis-hooks.nsh Normal file
View File

@@ -0,0 +1,53 @@
; NSIS installer hooks for yurou
; Override file association DefaultIcon to use custom file icon (yurou-sys.png → file-icon.ico)
!macro NSIS_HOOK_POSTINSTALL
; Read the file class (ProgID) registered by Tauri's APP_ASSOCIATE,
; then overwrite its DefaultIcon to point to our custom file-icon.ico
; instead of the application exe icon.
;
; This repeats for every extension we registered.
; If the extension was not associated (backup value is empty) we skip it.
!define YUROU_FILE_ICON '"$INSTDIR\file-icon.ico"'
; .md
ReadRegStr $0 SHCTX "Software\Classes\.md" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
; .markdown
ReadRegStr $0 SHCTX "Software\Classes\.markdown" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
; .mdown
ReadRegStr $0 SHCTX "Software\Classes\.mdown" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
; .mkdn
ReadRegStr $0 SHCTX "Software\Classes\.mkdn" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
; .mkd
ReadRegStr $0 SHCTX "Software\Classes\.mkd" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
; .mdwn
ReadRegStr $0 SHCTX "Software\Classes\.mdwn" ""
StrCmp $0 "" +2 +1
WriteRegStr SHCTX "Software\Classes\$0\DefaultIcon" "" ${YUROU_FILE_ICON}
!undef YUROU_FILE_ICON
; Notify Windows shell about the association change
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; Cleanup is handled automatically by APP_UNASSOCIATE which deletes the
; entire ProgID key — our custom DefaultIcon is removed along with it.
!macroend

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

@@ -34,10 +34,28 @@
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"resources": {
"icons/file-icon.ico": "file-icon.ico"
},
"fileAssociations": [
{
"ext": ["md", "markdown", "mdown", "mkdn", "mkd", "mdwn"],
"name": "Markdown",
"description": "Markdown Document",
"role": "Editor",
"mimeType": "text/markdown"
}
],
"windows": {
"nsis": {
"installerHooks": "nsis-hooks.nsh"
}
}
}
}

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import {computed, ref, watch, onMounted, onUnmounted, nextTick} from "vue";
import {computed, ref, 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,8 @@ 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 PARAGRAPH_INDENT_KEY = "yurou:paragraph-indent";
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 +81,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 +110,36 @@ 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 */
}
}
function loadParagraphIndent() {
try {
return localStorage.getItem(PARAGRAPH_INDENT_KEY) === "true";
} catch {
return false;
}
}
function saveParagraphIndent(value: boolean) {
try {
localStorage.setItem(PARAGRAPH_INDENT_KEY, String(value));
} catch { /* noop */
}
}
interface SyncConfig {
provider: SyncProvider;
webdav: {
@@ -151,7 +186,10 @@ const WORKSPACE_REFRESH_DELAY_MS = 140;
let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let workspaceRefreshInFlight = false;
let workspaceRefreshQueued = false;
let syncInFlight = false;
const syncInFlight = ref(false);
let fileOpenRequest = 0;
let saveNoticeTimer: ReturnType<typeof setTimeout> | null = null;
let closeSaveInFlight = false;
// ---- editor zoom (Ctrl+scroll) ----
const ZOOM_MIN = 0.7;
@@ -207,7 +245,8 @@ const showOutline = ref(false);
const defaultDocumentMode = ref<DocumentMode>(loadDefaultDocumentMode());
const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
const appTheme = ref<AppTheme>(loadAppTheme());
const showMoreMenu = ref(false);
const renderSingleLineBreaks = ref(loadRenderSingleLineBreaks());
const paragraphIndent = ref(loadParagraphIndent());
const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
@@ -230,6 +269,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 +361,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;
}
@@ -337,23 +397,6 @@ function updateAppTheme(theme: AppTheme) {
saveAppTheme(theme);
}
function handleExportPdf() {
showMoreMenu.value = false;
// TODO: implement PDF export
}
function closeMoreMenu() {
showMoreMenu.value = false;
}
watch(showMoreMenu, (val) => {
if (val) {
setTimeout(() => {
document.addEventListener("click", closeMoreMenu, {once: true});
}, 0);
}
});
onMounted(() => {
applyAppTheme(appTheme.value);
applyZoomCssVars();
@@ -367,6 +410,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 +426,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 +475,9 @@ function setDocumentContent(md: string) {
}
function resetMarkdownEditorState() {
fileOpenRequest += 1;
setDocumentContent("");
lastSavedContent = "";
currentFilePath.value = null;
currentFileType.value = "markdown";
resetMediaPreviewState();
@@ -430,7 +485,6 @@ function resetMarkdownEditorState() {
saveStatus.value = "";
showOutline.value = false;
documentMode.value = defaultDocumentMode.value;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
@@ -601,8 +655,19 @@ 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 updateParagraphIndent(value: boolean) {
paragraphIndent.value = value;
saveParagraphIndent(value);
}
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
@@ -696,6 +761,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;
@@ -713,36 +779,41 @@ async function refreshCurrentOpenFileFromDisk() {
}
async function runConfiguredSync(trigger: "startup" | "save" | "manual") {
if (!folderPath.value || syncInFlight) return false;
if (!folderPath.value || syncInFlight.value) return false;
const config = await loadSyncConfig();
if (trigger !== "manual" && !shouldRunSync(config, trigger)) return false;
if (!config || config.provider === "none") return false;
if (!config || config.provider === "none") {
if (trigger === "manual") {
showSaveNotice("请先在设置中配置同步方式", "error");
}
return false;
}
if (trigger !== "save") {
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return false;
}
syncInFlight = true;
syncInFlight.value = true;
showSaveNotice("正在同步...", "success");
try {
const result = await invoke<SyncResult>("sync_now", {
workspace: folderPath.value,
config,
});
saveStatus.value = describeSyncResult(result);
setTimeout(() => {
saveStatus.value = "";
}, 3600);
showSaveNotice(describeSyncResult(result), "success");
await refreshWorkspaceTree();
await refreshCurrentOpenFileFromDisk();
return true;
} catch (e) {
console.error("Sync failed:", e);
saveStatus.value = `同步失败: ${e}`;
showSaveNotice(`同步失败: ${e}`, "error");
return false;
} finally {
syncInFlight = false;
syncInFlight.value = false;
}
}
@@ -826,10 +897,10 @@ function openMediaPreview(path: string, fileType: Extract<WorkspaceFileType, "im
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
setDocumentContent("");
lastSavedContent = "";
hasDraftDocument.value = false;
saveStatus.value = "";
showOutline.value = false;
showMoreMenu.value = false;
editorResetKey.value += 1;
}
@@ -984,6 +1055,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 +1072,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 +1091,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 +1122,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 +1152,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 +1164,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 +1181,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 +1236,11 @@ registerTauriListener("menu-save-as", () => {
});
registerTauriListener("menu-new", () => {
doNew();
void doNew();
});
registerTauriListener("menu-quit", () => {
void closeWindow();
});
registerTauriListener("show-about", () => {
@@ -1147,6 +1275,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:settings-initial-page="settingsInitialPage"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
:recent-workspaces="recentWorkspaces"
@open-folder="onOpenFolder"
@switch-workspace="(path: string) => switchWorkspace(path)"
@@ -1160,6 +1290,8 @@ 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"
@update-paragraph-indent="updateParagraphIndent"
@prepare-workspace-sync="prepareWorkspaceSync"
@workspace-synced="handleWorkspaceSynced"
/>
@@ -1188,8 +1320,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
<i class="ri-menu-line text-base"></i>
</button>
</div>
<div v-else class="titlebar-brand app-muted-text shrink-0 pointer-events-none">
yurou
<div v-else class="titlebar-brand shrink-0 pointer-events-none">
<img src="/yurou-logo.png" alt="yurou" class="titlebar-logo" />
</div>
<!-- Center: Filename -->
@@ -1233,34 +1365,16 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
</button>
</div>
<!-- More menu -->
<div v-if="isMarkdownDocument" class="relative">
<button
class="toolbar-btn"
title="更多"
@click="showMoreMenu = !showMoreMenu"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
</svg>
</button>
<div
v-if="showMoreMenu"
class="dropdown-menu"
@click.stop
>
<button class="dropdown-item" @click="handleExportPdf">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M5 1h6l4 4v9a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1z"/>
<path d="M5 1v4h4"/>
<path d="M5 9h6M5 12h4"/>
</svg>
导出 PDF
</button>
</div>
</div>
<!-- Sync button -->
<button
v-if="folderPath"
class="toolbar-btn"
:title="syncInFlight ? '正在同步...' : '同步工作目录'"
:disabled="syncInFlight"
@click="runConfiguredSync('manual')"
>
<i :class="syncInFlight ? 'ri-loader-4-line sync-spinning' : 'ri-loop-left-line'"></i>
</button>
</div>
<!-- Window controls -->
@@ -1307,6 +1421,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
:model-value="content"
:document-mode="documentMode"
:file-path="currentFilePath"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
placeholder="输入 Markdown 内容... 支持标题粗体斜体代码块等"
@update:model-value="onUpdate"
@save-shortcut="doSave"
@@ -1379,6 +1495,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 +1544,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 +1604,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 {
@@ -1550,6 +1731,11 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
letter-spacing: 0;
}
.titlebar-logo {
height: 26px;
width: auto;
}
.titlebar-file-title {
width: 100%;
line-height: 1.2;
@@ -1658,38 +1844,6 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
box-shadow: 0 1px 3px var(--app-shadow);
}
.dropdown-menu {
position: absolute;
right: 0;
top: 100%;
margin-top: 4px;
background: var(--app-surface);
border: 1px solid var(--app-border-strong);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
min-width: 140px;
padding: 4px;
z-index: 50;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--app-text);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.dropdown-item:hover {
background-color: var(--app-surface-muted);
}
.start-actions {
background:
@@ -1720,4 +1874,15 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
text-decoration-color: currentColor;
outline: none;
}
/* Sync button spin animation */
@keyframes sync-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sync-spinning {
display: inline-block;
animation: sync-spin 1s linear infinite;
}
</style>

BIN
src/assets/yurou-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
src/assets/yurou-sys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -21,6 +21,11 @@ interface WorkspaceEntry {
path: string;
}
interface TreeExpansionCommand {
id: number;
expanded: boolean;
}
const props = withDefaults(
defineProps<{
@@ -31,6 +36,8 @@ const props = withDefaults(
settingsInitialPage?: SettingsPage;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
recentWorkspaces?: WorkspaceEntry[];
}>(),
{
@@ -39,6 +46,8 @@ const props = withDefaults(
settingsInitialPage: "about",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
paragraphIndent: false,
recentWorkspaces: () => [],
},
);
@@ -55,6 +64,8 @@ const emit = defineEmits<{
renameEntry: [path: string, newName: string];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
"search-result-click": [filePath: string, lineNumber: number];
@@ -455,6 +466,7 @@ watch(showWorkspaceMenu, (open) => {
watch(() => props.folderPath, () => {
closeWorkspaceMenu();
treeExpansionCommand.value = null;
});
onBeforeUnmount(() => {
@@ -532,6 +544,16 @@ function handleCreateRootFile() {
});
}
const treeExpansionCommand = ref<TreeExpansionCommand | null>(null);
let treeExpansionCommandId = 0;
function setAllFoldersExpanded(expanded: boolean) {
treeExpansionCommand.value = {
id: ++treeExpansionCommandId,
expanded,
};
}
</script>
@@ -597,6 +619,25 @@ function handleCreateRootFile() {
>
<i class="ri-file-add-line"></i>
</button>
<span class="tree-toolbar-separator" aria-hidden="true"></span>
<button
class="tree-toolbar-btn"
type="button"
title="全部展开"
aria-label="全部展开工作目录文件夹"
@click="setAllFoldersExpanded(true)"
>
<i class="ri-expand-vertical-line"></i>
</button>
<button
class="tree-toolbar-btn"
type="button"
title="全部折叠"
aria-label="全部折叠工作目录文件夹"
@click="setAllFoldersExpanded(false)"
>
<i class="ri-collapse-vertical-line"></i>
</button>
</div>
<!-- File tree -->
<div
@@ -609,6 +650,7 @@ function handleCreateRootFile() {
:entry="entry"
:depth="0"
:currentFilePath="currentFilePath"
:expansion-command="treeExpansionCommand"
@toggle-folder="(path: string) => emit('toggleFolder', path)"
@open-file="(path: string) => emit('openFile', path)"
@create-folder="(path: string) => emit('createFolder', path)"
@@ -842,8 +884,12 @@ function handleCreateRootFile() {
:workspace-path="folderPath"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:render-single-line-breaks="renderSingleLineBreaks"
:paragraph-indent="paragraphIndent"
@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)"
@update-paragraph-indent="(value: boolean) => emit('updateParagraphIndent', value)"
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
@workspace-synced="emit('workspaceSynced')"
/>
@@ -1090,6 +1136,13 @@ function handleCreateRootFile() {
color: var(--app-text);
}
.tree-toolbar-separator {
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--app-border-strong);
}
.sidebar-search-input {
border: 1px solid var(--app-border-strong);
background: var(--app-hover);

View File

@@ -0,0 +1,496 @@
<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];
"backspace-at-start": [];
}>();
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,
{
key: "Backspace",
run(view) {
const selection = view.state.selection.main;
if (selection.empty && selection.head === 0) {
emit("backspace-at-start");
return true;
}
return false;
},
},
...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();
}
function setCursorPosition(pos: number) {
const view = editorView;
if (!view) return;
const safePos = Math.max(0, Math.min(pos, view.state.doc.length));
view.dispatch({ selection: EditorSelection.cursor(safePos), scrollIntoView: true });
view.focus();
}
defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine, setCursorPosition });
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

@@ -3,12 +3,16 @@ import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import { Fancybox } from "@fancyapps/ui";
import "@fancyapps/ui/dist/fancybox/fancybox.css";
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";
@@ -66,6 +70,7 @@ interface MarkdownCodeEditorApi {
focusAtEnd: () => void;
replaceRange: (text: string, from?: number, to?: number) => void;
scrollToLine: (lineNumber: number) => void;
setCursorPosition: (pos: number) => void;
}
const props = withDefaults(
@@ -75,6 +80,8 @@ const props = withDefaults(
documentMode?: DocumentMode;
previewOnly?: boolean;
filePath?: string | null;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(),
{
modelValue: "",
@@ -82,6 +89,8 @@ const props = withDefaults(
documentMode: undefined,
previewOnly: false,
filePath: null,
renderSingleLineBreaks: false,
paragraphIndent: false,
},
);
@@ -220,14 +229,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);
@@ -256,8 +274,12 @@ const pendingLocalModelUpdate = ref(false);
const isEditingEmptyDocument = computed(
() => editingBlockId.value === EMPTY_BLOCK_ID,
);
const previewParagraphStyle = computed(() => ({
"--paragraph-indent": props.paragraphIndent ? "2em" : "0",
}));
let modeScrollRestoreFrame: number | null = null;
let renderRequestId = 0;
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";
interface ScrollSnapshot {
ratio: number;
@@ -350,7 +372,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 +492,13 @@ watch(
{ immediate: true },
);
watch(
() => props.renderSingleLineBreaks,
() => {
syncRenderedState(props.modelValue);
},
);
// ── Editor handlers ────────────────────────────────────────────────
function focusBlockEditor() {
@@ -567,6 +597,56 @@ function flushPendingChanges(): string | undefined {
return finalizeBlockEdit();
}
async function mergeCurrentBlockWithPrevious() {
if (activeDocumentMode.value !== "mixed") return;
const blockId = editingBlockId.value;
if (!blockId) return;
const blockIndex = markdownBlocks.value.findIndex((b) => b.id === blockId);
if (blockIndex <= 0) return;
const previousBlock = markdownBlocks.value[blockIndex - 1];
const currentBlock = markdownBlocks.value[blockIndex];
// 将当前编辑中的源码与前一个块的源码合并
const mergedSource = previousBlock.source + editingSource.value;
// 构建新文档:用合并后的源码替换两个块的范围
const nextValue =
props.modelValue.slice(0, previousBlock.startOffset) +
mergedSource +
props.modelValue.slice(currentBlock.endOffset);
// 记录合并点位置(上一个块的原末尾),用于后续光标定位
const cursorOffset = previousBlock.source.length;
// 取消当前编辑状态
cancelBlockEdit();
// 更新文档
emitModelValue(nextValue);
// 等待重新解析块结构
await parseBlocks(nextValue);
// 找到合并后的新块startOffset 仍为 previousBlock.startOffset
const newBlock = markdownBlocks.value.find(
(b) => b.startOffset === previousBlock.startOffset,
);
if (newBlock) {
// 进入编辑模式,光标定位在合并点
editingBlockId.value = newBlock.id;
editingSource.value = newBlock.source;
editingBaseValue.value = nextValue;
editingRange.value = {
startOffset: newBlock.startOffset,
endOffset: newBlock.endOffset,
};
await nextTick();
blockCodeRef.value?.setCursorPosition(cursorOffset);
}
}
watch(activeDocumentMode, (mode, oldMode) => {
const scrollSnapshot = captureScrollSnapshot();
let committedValue: string | undefined;
@@ -579,7 +659,17 @@ watch(activeDocumentMode, (mode, oldMode) => {
restoreScrollAfterModeChange(scrollSnapshot);
});
onMounted(() => {
Fancybox.bind(scrollRef.value ?? null, previewImageSelector, {
groupAll: true,
placeFocusBack: true,
theme: "auto",
});
});
onBeforeUnmount(() => {
Fancybox.unbind(scrollRef.value ?? null, previewImageSelector);
if (modeScrollRestoreFrame !== null) {
cancelAnimationFrame(modeScrollRestoreFrame);
}
@@ -918,7 +1008,7 @@ defineExpose({
@keydown.capture="onRootKeydown"
>
<div
class="mx-auto px-8 py-4 h-full"
class="mx-auto px-8 pt-4 pb-10 min-h-full"
:style="{
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
@@ -941,11 +1031,11 @@ defineExpose({
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
<div v-else class="markdown-preview" v-html="renderedHtml" />
<div v-else class="markdown-preview" :style="previewParagraphStyle" v-html="renderedHtml" />
</div>
<!-- Mixed mode -->
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed">
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed" :style="previewParagraphStyle">
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
@@ -978,7 +1068,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"
@@ -993,6 +1086,7 @@ defineExpose({
@escape="cancelBlockEdit"
@blur="finalizeBlockEdit"
@paste="onBlockPaste"
@backspace-at-start="mergeCurrentBlockWithPrevious"
/>
<div
v-else
@@ -1054,7 +1148,7 @@ 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.8em 0; text-indent: var(--paragraph-indent, 0); }
.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
.markdown-preview em { font-style: italic; }
@@ -1157,6 +1251,10 @@ defineExpose({
margin: 0.5em 0;
}
.markdown-preview:not(.markdown-mixed) img {
cursor: zoom-in;
}
/* ── Mixed mode block editing ────────────────────────────────────── */
.markdown-block {
@@ -1174,15 +1272,16 @@ defineExpose({
box-shadow 0.15s ease;
}
.markdown-block:not(.markdown-block-editing):hover > .markdown-block-preview > * {
background-color: var(--app-surface-muted);
cursor: text;
}
.markdown-block-editing {
display: block;
}
.markdown-mixed
> .markdown-block-paragraph
+ .markdown-block-paragraph.markdown-block-editing {
margin-top: 0;
}
.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,16 @@ const props = withDefaults(
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
paragraphIndent: false,
},
);
@@ -68,6 +94,8 @@ const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
@@ -83,8 +111,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 +125,30 @@ 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
.filter((file) => file.action !== "skip")
.sort((left, right) => left.path.localeCompare(right.path));
});
const menuItems: Array<{
id: SettingsPage;
@@ -270,33 +321,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 +384,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 +431,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 +452,7 @@ watch(
if (!show) return;
activePage.value = props.initialPage;
updateStatus.value = "";
syncFeedback.value = null;
void loadAppVersion();
void loadSyncConfig();
},
@@ -421,6 +517,9 @@ onBeforeUnmount(() => {
<div v-if="activePage === 'about'" class="settings-content">
<section class="settings-about-card" aria-label="关于 yurou">
<div class="settings-about-logo">
<img src="/yurou-logo.png" alt="yurou logo" class="settings-logo-img" />
</div>
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>版本 {{ appVersion }}</h3>
@@ -497,6 +596,34 @@ 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>
<div class="settings-row">
<div class="settings-copy">
<h3>段落首行缩进</h3>
<p>开启后预览和混合模式下段落首行会缩进两个字符更符合中文排版习惯</p>
</div>
<label class="settings-switch" title="段落首行缩进">
<input
:checked="paragraphIndent"
type="checkbox"
@change="emit('updateParagraphIndent', ($event.target as HTMLInputElement).checked)"
/>
<span></span>
</label>
</div>
</section>
</div>
@@ -669,41 +796,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>
@@ -892,6 +1097,20 @@ onBeforeUnmount(() => {
box-shadow: inset 0 0 0 1px var(--app-border);
}
.settings-about-logo {
display: flex;
justify-content: center;
align-items: center;
padding: 28px 20px 8px;
}
.settings-logo-img {
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 16px;
}
.settings-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -948,9 +1167,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 +1189,7 @@ onBeforeUnmount(() => {
font-size: 13px;
font-weight: 600;
line-height: 1;
gap: 6px;
cursor: pointer;
transition:
background-color 0.15s ease,
@@ -983,7 +1210,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 +1376,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 +1712,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>

View File

@@ -15,12 +15,18 @@ interface DirEntry {
children?: DirEntry[];
}
interface TreeExpansionCommand {
id: number;
expanded: boolean;
}
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
const props = defineProps<{
entry: DirEntry;
depth: number;
currentFilePath?: string | null;
expansionCommand?: TreeExpansionCommand | null;
}>();
const emit = defineEmits<{
@@ -33,6 +39,7 @@ const emit = defineEmits<{
}>();
const expanded = ref(true);
const handledExpansionCommandId = ref(0);
const contextMenuOpen = ref(false);
const contextMenuPoint = ref<{ x: number; y: number } | null>(null);
const contextMenuStyle = computed(() => {
@@ -76,12 +83,35 @@ function onContextMenuKeydown(event: KeyboardEvent) {
watch(
() => props.entry.children,
(children, oldChildren) => {
if (children !== undefined && oldChildren === undefined) {
if (
children !== undefined &&
oldChildren === undefined &&
props.expansionCommand?.expanded !== false
) {
expanded.value = true;
}
},
);
watch(
() => props.expansionCommand,
(command) => {
if (
!command ||
!props.entry.is_dir ||
command.id <= handledExpansionCommandId.value
) return;
handledExpansionCommandId.value = command.id;
expanded.value = command.expanded;
if (command.expanded && props.entry.children === undefined) {
emit("toggleFolder", props.entry.path);
}
},
{ immediate: true },
);
const isActive = computed(() => {
return props.currentFilePath != null && props.currentFilePath === props.entry.path;
});
@@ -307,6 +337,7 @@ function onContextMenu(event: MouseEvent) {
:entry="child"
:depth="depth + 1"
:currentFilePath="currentFilePath"
:expansion-command="expansionCommand"
@toggle-folder="(path: string) => emit('toggleFolder', path)"
@open-file="(path: string) => emit('openFile', path)"
@create-folder="(path: string) => emit('createFolder', path)"

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),
});
},
}));
}