Update dependencies and add search functionality
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/view": "^6.39.12",
|
"@codemirror/view": "^6.39.12",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|||||||
142
src-tauri/Cargo.lock
generated
142
src-tauri/Cargo.lock
generated
@@ -1574,9 +1574,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ico"
|
name = "ico"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
@@ -2244,38 +2244,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-cloud-kit",
|
|
||||||
"objc2-core-data",
|
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-core-image",
|
|
||||||
"objc2-core-text",
|
|
||||||
"objc2-core-video",
|
|
||||||
"objc2-foundation",
|
|
||||||
"objc2-quartz-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-cloud-kit"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-data"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2303,41 +2274,6 @@ dependencies = [
|
|||||||
"objc2-io-surface",
|
"objc2-io-surface",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-image"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
|
||||||
dependencies = [
|
|
||||||
"objc2",
|
|
||||||
"objc2-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-text"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-video"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
|
||||||
"objc2-io-surface",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -2361,7 +2297,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2377,16 +2312,6 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-javascript-core"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
|
|
||||||
dependencies = [
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2399,17 +2324,6 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-security"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2434,8 +2348,6 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"objc2-javascript-core",
|
|
||||||
"objc2-security",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3074,9 +2986,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3093,7 +3005,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -4010,9 +3921,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.9.5"
|
version = "2.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033"
|
checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -4061,9 +3972,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-build"
|
name = "tauri-build"
|
||||||
version = "2.5.3"
|
version = "2.5.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
@@ -4083,9 +3994,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-codegen"
|
name = "tauri-codegen"
|
||||||
version = "2.5.2"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf"
|
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -4110,9 +4021,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-macros"
|
name = "tauri-macros"
|
||||||
version = "2.5.2"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde"
|
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4236,9 +4147,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.2"
|
version = "2.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892"
|
checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -4261,9 +4172,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime-wry"
|
name = "tauri-runtime-wry"
|
||||||
version = "2.9.3"
|
version = "2.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065"
|
checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
@@ -4271,7 +4182,6 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
@@ -4288,9 +4198,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.8.1"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490"
|
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -5001,9 +4911,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -5094,9 +5004,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.1"
|
version = "2.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
|
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"cairo-rs",
|
"cairo-rs",
|
||||||
@@ -5118,9 +5028,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webkit2gtk-sys"
|
name = "webkit2gtk-sys"
|
||||||
version = "2.0.1"
|
version = "2.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
|
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
@@ -5739,9 +5649,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wry"
|
name = "wry"
|
||||||
version = "0.53.5"
|
version = "0.54.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
|
checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ description = "TextDB"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2.5", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2.10", features = [] }
|
||||||
tauri-plugin-clipboard-manager = { version = "2" }
|
tauri-plugin-clipboard-manager = { version = "2" }
|
||||||
tauri-plugin-dialog = { version = "2" }
|
tauri-plugin-dialog = { version = "2" }
|
||||||
tauri-plugin-fs = { version = "2" }
|
tauri-plugin-fs = { version = "2" }
|
||||||
|
|||||||
@@ -63,7 +63,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ext": [
|
"ext": [
|
||||||
"md"
|
"md",
|
||||||
|
"markdown"
|
||||||
],
|
],
|
||||||
"name": "Markdown Document",
|
"name": "Markdown Document",
|
||||||
"description": "Markdown document",
|
"description": "Markdown document",
|
||||||
@@ -71,8 +72,7 @@
|
|||||||
"role": "Editor",
|
"role": "Editor",
|
||||||
"rank": "Default",
|
"rank": "Default",
|
||||||
"contentTypes": [
|
"contentTypes": [
|
||||||
"net.daringfireball.markdown",
|
"net.daringfireball.markdown"
|
||||||
"public.plain-text"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
326
src/App.tsx
326
src/App.tsx
@@ -9,6 +9,13 @@ import { appDataDir } from "@tauri-apps/api/path";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { Compartment, EditorState, Transaction } from "@codemirror/state";
|
import { Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
closeSearchPanel,
|
||||||
|
openSearchPanel,
|
||||||
|
search,
|
||||||
|
searchKeymap,
|
||||||
|
searchPanelOpen
|
||||||
|
} from "@codemirror/search";
|
||||||
import {
|
import {
|
||||||
EditorView,
|
EditorView,
|
||||||
keymap,
|
keymap,
|
||||||
@@ -88,6 +95,20 @@ type HistoryEntry = {
|
|||||||
baseVersionId?: string | null;
|
baseVersionId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConversionJob = {
|
||||||
|
sourceTextId: string;
|
||||||
|
sourceTitle: string;
|
||||||
|
sourceBody: string;
|
||||||
|
controller: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentStats = {
|
||||||
|
characters: number;
|
||||||
|
words: number;
|
||||||
|
sentences: number;
|
||||||
|
estimatedTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
type SidebarEntry =
|
type SidebarEntry =
|
||||||
| { kind: "folder"; item: Folder }
|
| { kind: "folder"; item: Folder }
|
||||||
| { kind: "text"; item: Text };
|
| { kind: "text"; item: Text };
|
||||||
@@ -101,6 +122,59 @@ Only add Markdown structure (such as headings, lists, code blocks, tables, quote
|
|||||||
Keep the content itself unaltered and do not translate, summarize or rephrase. Only use your Markdown-formatting skills.
|
Keep the content itself unaltered and do not translate, summarize or rephrase. Only use your Markdown-formatting skills.
|
||||||
Text:`;
|
Text:`;
|
||||||
|
|
||||||
|
const graphemeSegmenter =
|
||||||
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||||
|
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||||
|
: null;
|
||||||
|
const wordSegmenter =
|
||||||
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||||
|
? new Intl.Segmenter(undefined, { granularity: "word" })
|
||||||
|
: null;
|
||||||
|
const sentenceSegmenter =
|
||||||
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||||
|
? new Intl.Segmenter(undefined, { granularity: "sentence" })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function getDocumentStats(text: string): DocumentStats {
|
||||||
|
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
const characters = graphemeSegmenter
|
||||||
|
? Array.from(graphemeSegmenter.segment(normalized)).length
|
||||||
|
: Array.from(normalized).length;
|
||||||
|
const words = wordSegmenter
|
||||||
|
? Array.from(wordSegmenter.segment(normalized)).filter((segment) => segment.isWordLike).length
|
||||||
|
: (normalized.match(/\b[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
|
||||||
|
const sentences = sentenceSegmenter
|
||||||
|
? Array.from(sentenceSegmenter.segment(normalized)).filter(
|
||||||
|
(segment) => segment.segment.trim().length > 0
|
||||||
|
).length
|
||||||
|
: (normalized.match(/[^.!?]+(?:[.!?]+|$)/g) ?? [])
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters,
|
||||||
|
words,
|
||||||
|
sentences,
|
||||||
|
estimatedTokens: Math.round((words * 4) / 3)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportedTitle(filePath: string) {
|
||||||
|
const filename = filePath.split(/[\\/]/).pop()?.trim() || DEFAULT_TITLE;
|
||||||
|
const lastDot = filename.lastIndexOf(".");
|
||||||
|
if (lastDot <= 0) return filename || DEFAULT_TITLE;
|
||||||
|
const stripped = filename.slice(0, lastDot).trim();
|
||||||
|
return stripped || filename || DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileLabel(filePath: string) {
|
||||||
|
return filePath.split(/[\\/]/).pop()?.trim() || filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [texts, setTexts] = useState<Text[]>([]);
|
const [texts, setTexts] = useState<Text[]>([]);
|
||||||
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
|
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
|
||||||
@@ -176,24 +250,31 @@ export default function App() {
|
|||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
const [ollamaLoading, setOllamaLoading] = useState(false);
|
const [ollamaLoading, setOllamaLoading] = useState(false);
|
||||||
const [ollamaError, setOllamaError] = useState<string | null>(null);
|
const [ollamaError, setOllamaError] = useState<string | null>(null);
|
||||||
const [isConverting, setIsConverting] = useState(false);
|
const [conversionJob, setConversionJob] = useState<ConversionJob | null>(null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
|
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
|
||||||
});
|
});
|
||||||
const [editorReady, setEditorReady] = useState(false);
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
|
|
||||||
const bodyRef = useRef(body);
|
const bodyRef = useRef(body);
|
||||||
|
const selectedTextIdRef = useRef<string | null>(selectedTextId);
|
||||||
|
const historyOpenRef = useRef(historyOpen);
|
||||||
|
const viewingVersionRef = useRef<HistoryEntry | null>(viewingVersion);
|
||||||
const editorViewRef = useRef<EditorView | null>(null);
|
const editorViewRef = useRef<EditorView | null>(null);
|
||||||
const editorValueRef = useRef("");
|
const editorValueRef = useRef("");
|
||||||
const lineNumbersCompartmentRef = useRef(new Compartment());
|
const lineNumbersCompartmentRef = useRef(new Compartment());
|
||||||
const editableCompartmentRef = useRef(new Compartment());
|
const editableCompartmentRef = useRef(new Compartment());
|
||||||
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
|
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
|
||||||
const recentOpenRef = useRef(new Map<string, number>());
|
const recentOpenRef = useRef(new Map<string, number>());
|
||||||
|
const searchRestoreSplitRef = useRef<boolean | null>(null);
|
||||||
const ignoreTextBlurRef = useRef(false);
|
const ignoreTextBlurRef = useRef(false);
|
||||||
const ignoreFolderBlurRef = useRef(false);
|
const ignoreFolderBlurRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
bodyRef.current = body;
|
bodyRef.current = body;
|
||||||
|
selectedTextIdRef.current = selectedTextId;
|
||||||
|
historyOpenRef.current = historyOpen;
|
||||||
|
viewingVersionRef.current = viewingVersion;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -258,10 +339,16 @@ export default function App() {
|
|||||||
extensions: [
|
extensions: [
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
history(),
|
history(),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
search(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||||
lineNumbersCompartmentRef.current.of([]),
|
lineNumbersCompartmentRef.current.of([]),
|
||||||
editableCompartmentRef.current.of(EditorView.editable.of(true)),
|
editableCompartmentRef.current.of(EditorView.editable.of(true)),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (!searchPanelOpen(update.state) && searchRestoreSplitRef.current !== null) {
|
||||||
|
const nextSplitView = searchRestoreSplitRef.current;
|
||||||
|
searchRestoreSplitRef.current = null;
|
||||||
|
setSplitView(nextSplitView);
|
||||||
|
}
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged) return;
|
||||||
const value = update.state.doc.toString();
|
const value = update.state.doc.toString();
|
||||||
editorValueRef.current = value;
|
editorValueRef.current = value;
|
||||||
@@ -279,10 +366,12 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isViewingHistory = viewingVersion !== null;
|
const isViewingHistory = viewingVersion !== null;
|
||||||
|
const isConverting = conversionJob !== null;
|
||||||
const isDirty = !isViewingHistory && body !== lastPersistedBody;
|
const isDirty = !isViewingHistory && body !== lastPersistedBody;
|
||||||
const hasText = body.trim().length > 0;
|
const hasText = body.trim().length > 0;
|
||||||
const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView);
|
const showLineNumbersActive = showLineNumbers && (!markdownPreview || splitView);
|
||||||
const hasSearch = search.trim().length > 0;
|
const hasSearch = search.trim().length > 0;
|
||||||
|
const documentStats = useMemo(() => getDocumentStats(body), [body]);
|
||||||
const markdownHtml = useMemo(
|
const markdownHtml = useMemo(
|
||||||
() => (markdownPreview ? markdownToHTML(body) : ""),
|
() => (markdownPreview ? markdownToHTML(body) : ""),
|
||||||
[body, markdownPreview]
|
[body, markdownPreview]
|
||||||
@@ -435,6 +524,31 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [markdownPreview]);
|
}, [markdownPreview]);
|
||||||
|
|
||||||
|
const openDocumentSearch = useCallback(() => {
|
||||||
|
if (!selectedTextIdRef.current) return false;
|
||||||
|
|
||||||
|
const revealEditor = markdownPreview && !splitView;
|
||||||
|
if (revealEditor) {
|
||||||
|
searchRestoreSplitRef.current = false;
|
||||||
|
setSplitView(true);
|
||||||
|
} else {
|
||||||
|
searchRestoreSplitRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
openSearchPanel(view);
|
||||||
|
view.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [markdownPreview, splitView]);
|
||||||
|
|
||||||
|
const handleCancelConversion = useCallback(() => {
|
||||||
|
conversionJob?.controller.abort();
|
||||||
|
}, [conversionJob]);
|
||||||
|
|
||||||
const statusKey = useMemo(() => {
|
const statusKey = useMemo(() => {
|
||||||
if (isViewingHistory) return "history";
|
if (isViewingHistory) return "history";
|
||||||
if (isDirty) return "unsaved";
|
if (isDirty) return "unsaved";
|
||||||
@@ -456,6 +570,13 @@ export default function App() {
|
|||||||
return "Saved";
|
return "Saved";
|
||||||
}
|
}
|
||||||
}, [statusKey]);
|
}, [statusKey]);
|
||||||
|
const conversionLabel = useMemo(() => {
|
||||||
|
if (!conversionJob) return null;
|
||||||
|
if (conversionJob.sourceTextId === selectedTextId) {
|
||||||
|
return "Converting Markdown";
|
||||||
|
}
|
||||||
|
return `Converting ${conversionJob.sourceTitle}`;
|
||||||
|
}, [conversionJob, selectedTextId]);
|
||||||
|
|
||||||
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
|
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
|
||||||
const folderIconSrc = theme === "light" ? folderIconBright : folderIcon;
|
const folderIconSrc = theme === "light" ? folderIconBright : folderIcon;
|
||||||
@@ -552,11 +673,12 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshVersions = useCallback(async () => {
|
const refreshVersions = useCallback(async (targetTextId?: string | null) => {
|
||||||
if (!selectedTextId || !historyOpen) return;
|
const textId = targetTextId ?? selectedTextIdRef.current;
|
||||||
|
if (!textId || !historyOpenRef.current) return;
|
||||||
const [manualRows, draft] = await Promise.all([
|
const [manualRows, draft] = await Promise.all([
|
||||||
listVersions(selectedTextId),
|
listVersions(textId),
|
||||||
getDraft(selectedTextId)
|
getDraft(textId)
|
||||||
]);
|
]);
|
||||||
const manualItems: HistoryEntry[] = manualRows.map((row) => ({
|
const manualItems: HistoryEntry[] = manualRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -567,7 +689,7 @@ export default function App() {
|
|||||||
const draftItem: HistoryEntry[] = draft
|
const draftItem: HistoryEntry[] = draft
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: `draft:${selectedTextId}`,
|
id: `draft:${textId}`,
|
||||||
created_at: draft.updated_at,
|
created_at: draft.updated_at,
|
||||||
kind: "draft",
|
kind: "draft",
|
||||||
body: draft.body,
|
body: draft.body,
|
||||||
@@ -579,7 +701,7 @@ export default function App() {
|
|||||||
(a, b) => b.created_at - a.created_at
|
(a, b) => b.created_at - a.created_at
|
||||||
);
|
);
|
||||||
setHistoryItems(combined);
|
setHistoryItems(combined);
|
||||||
}, [historyOpen, selectedTextId]);
|
}, []);
|
||||||
|
|
||||||
const handleConvertToMarkdown = useCallback(async () => {
|
const handleConvertToMarkdown = useCallback(async () => {
|
||||||
if (!selectedTextId || !hasText || isViewingHistory || isConverting) return;
|
if (!selectedTextId || !hasText || isViewingHistory || isConverting) return;
|
||||||
@@ -592,13 +714,23 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
const prompt = (ollamaPrompt || DEFAULT_OLLAMA_PROMPT).trim();
|
const prompt = (ollamaPrompt || DEFAULT_OLLAMA_PROMPT).trim();
|
||||||
const fullPrompt = `${prompt}\n${body}`;
|
const sourceTextId = selectedTextId;
|
||||||
setIsConverting(true);
|
const sourceBody = body;
|
||||||
|
const sourceTitle = title.trim() || DEFAULT_TITLE;
|
||||||
|
const fullPrompt = `${prompt}\n${sourceBody}`;
|
||||||
|
setConversionJob({
|
||||||
|
sourceTextId,
|
||||||
|
sourceTitle,
|
||||||
|
sourceBody,
|
||||||
|
controller
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${normalizedOllamaUrl}/api/generate`, {
|
const response = await fetch(`${normalizedOllamaUrl}/api/generate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: ollamaModel,
|
model: ollamaModel,
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
@@ -613,26 +745,57 @@ export default function App() {
|
|||||||
if (!resultText) {
|
if (!resultText) {
|
||||||
throw new Error("Ollama returned an empty response.");
|
throw new Error("Ollama returned an empty response.");
|
||||||
}
|
}
|
||||||
const normalizedTitle = title.trim() || DEFAULT_TITLE;
|
if (controller.signal.aborted) {
|
||||||
const result = await saveManualVersion(
|
return;
|
||||||
selectedTextId,
|
}
|
||||||
normalizedTitle,
|
const currentText = await getText(sourceTextId);
|
||||||
resultText
|
const normalizedTitle = currentText?.title?.trim() || sourceTitle;
|
||||||
);
|
if (controller.signal.aborted) {
|
||||||
setBody(resultText);
|
return;
|
||||||
setLastPersistedBody(resultText);
|
}
|
||||||
setLastPersistedTitle(normalizedTitle);
|
const result = await saveManualVersion(sourceTextId, normalizedTitle, resultText);
|
||||||
setHasDraft(false);
|
const hasLiveEditsOnSource =
|
||||||
setRestoredDraft(false);
|
selectedTextIdRef.current === sourceTextId &&
|
||||||
setLatestManualVersionId(result.versionId);
|
viewingVersionRef.current === null &&
|
||||||
setDraftBaseVersionId(result.versionId);
|
bodyRef.current !== sourceBody;
|
||||||
setSelectedHistoryId(result.versionId);
|
|
||||||
setViewingVersion(null);
|
const canApplyToVisibleEditor =
|
||||||
historySnapshotRef.current = null;
|
selectedTextIdRef.current === sourceTextId &&
|
||||||
setMarkdownPreview(true);
|
viewingVersionRef.current === null &&
|
||||||
|
!hasLiveEditsOnSource;
|
||||||
|
|
||||||
|
if (hasLiveEditsOnSource) {
|
||||||
|
const currentBody = bodyRef.current;
|
||||||
|
await upsertDraft(sourceTextId, currentBody, result.versionId);
|
||||||
|
setHasDraft(true);
|
||||||
|
setLastPersistedBody(currentBody);
|
||||||
|
setLatestManualVersionId(result.versionId);
|
||||||
|
setDraftBaseVersionId(result.versionId);
|
||||||
|
setSelectedHistoryId(`draft:${sourceTextId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canApplyToVisibleEditor) {
|
||||||
|
setBody(resultText);
|
||||||
|
setLastPersistedBody(resultText);
|
||||||
|
setLastPersistedTitle(normalizedTitle);
|
||||||
|
setHasDraft(false);
|
||||||
|
setRestoredDraft(false);
|
||||||
|
setLatestManualVersionId(result.versionId);
|
||||||
|
setDraftBaseVersionId(result.versionId);
|
||||||
|
setSelectedHistoryId(result.versionId);
|
||||||
|
setViewingVersion(null);
|
||||||
|
historySnapshotRef.current = null;
|
||||||
|
setMarkdownPreview(true);
|
||||||
|
}
|
||||||
|
|
||||||
await refreshTexts();
|
await refreshTexts();
|
||||||
await refreshVersions();
|
if (selectedTextIdRef.current === sourceTextId && historyOpenRef.current) {
|
||||||
|
await refreshVersions(sourceTextId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to convert with Ollama", error);
|
console.error("Failed to convert with Ollama", error);
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: "Ollama error",
|
title: "Ollama error",
|
||||||
@@ -641,7 +804,9 @@ export default function App() {
|
|||||||
onConfirm: () => {}
|
onConfirm: () => {}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsConverting(false);
|
setConversionJob((current) =>
|
||||||
|
current?.controller === controller ? null : current
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
body,
|
body,
|
||||||
@@ -687,10 +852,15 @@ export default function App() {
|
|||||||
refreshVersions().catch((error) => {
|
refreshVersions().catch((error) => {
|
||||||
console.error("Failed to load versions", error);
|
console.error("Failed to load versions", error);
|
||||||
});
|
});
|
||||||
}, [historyOpen, refreshVersions]);
|
}, [historyOpen, refreshVersions, selectedTextId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTextId) {
|
if (!selectedTextId) {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (view) {
|
||||||
|
closeSearchPanel(view);
|
||||||
|
}
|
||||||
|
searchRestoreSplitRef.current = null;
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setLastPersistedTitle("");
|
setLastPersistedTitle("");
|
||||||
setBody("");
|
setBody("");
|
||||||
@@ -701,7 +871,6 @@ export default function App() {
|
|||||||
setDraftBaseVersionId(null);
|
setDraftBaseVersionId(null);
|
||||||
setViewingVersion(null);
|
setViewingVersion(null);
|
||||||
setSelectedHistoryId(null);
|
setSelectedHistoryId(null);
|
||||||
setMarkdownPreview(false);
|
|
||||||
historySnapshotRef.current = null;
|
historySnapshotRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -738,7 +907,6 @@ export default function App() {
|
|||||||
setSelectedHistoryId(
|
setSelectedHistoryId(
|
||||||
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
|
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
|
||||||
);
|
);
|
||||||
setMarkdownPreview(false);
|
|
||||||
historySnapshotRef.current = null;
|
historySnapshotRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1112,14 +1280,26 @@ export default function App() {
|
|||||||
const createTextFromFile = useCallback(
|
const createTextFromFile = useCallback(
|
||||||
async (filePath: string) => {
|
async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE;
|
const title = getImportedTitle(filePath);
|
||||||
const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE;
|
|
||||||
const contents = await readTextFile(filePath);
|
const contents = await readTextFile(filePath);
|
||||||
|
if (contents.includes("\u0000")) {
|
||||||
|
throw new Error("This file appears to be binary and cannot be opened as text.");
|
||||||
|
}
|
||||||
const { textId } = await createText(title, contents, null);
|
const { textId } = await createText(title, contents, null);
|
||||||
await refreshTexts();
|
await refreshTexts();
|
||||||
setSelectedTextId(textId);
|
setSelectedTextId(textId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open text file", error);
|
console.error("Failed to open text file", error);
|
||||||
|
const fileLabel = getFileLabel(filePath);
|
||||||
|
setConfirmState({
|
||||||
|
title: "Open file error",
|
||||||
|
message: `Unable to open "${fileLabel}" as text. ${getErrorMessage(
|
||||||
|
error,
|
||||||
|
"The file could not be decoded as text."
|
||||||
|
)}`,
|
||||||
|
actionLabel: "OK",
|
||||||
|
onConfirm: () => {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshTexts]
|
[refreshTexts]
|
||||||
@@ -1128,12 +1308,8 @@ export default function App() {
|
|||||||
const handleFilePaths = useCallback(
|
const handleFilePaths = useCallback(
|
||||||
async (paths: string[]) => {
|
async (paths: string[]) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const txtPaths = paths.filter((path) => {
|
|
||||||
const lower = path.toLowerCase();
|
|
||||||
return lower.endsWith(".txt") || lower.endsWith(".md");
|
|
||||||
});
|
|
||||||
const recent = recentOpenRef.current;
|
const recent = recentOpenRef.current;
|
||||||
for (const path of txtPaths) {
|
for (const path of paths) {
|
||||||
const key = path.toLowerCase();
|
const key = path.toLowerCase();
|
||||||
const last = recent.get(key);
|
const last = recent.get(key);
|
||||||
if (last && now - last < 1000) continue;
|
if (last && now - last < 1000) continue;
|
||||||
@@ -1193,7 +1369,6 @@ export default function App() {
|
|||||||
const path = await open({
|
const path = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
directory: false,
|
directory: false,
|
||||||
filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }],
|
|
||||||
defaultPath: baseDir
|
defaultPath: baseDir
|
||||||
});
|
});
|
||||||
if (!path || Array.isArray(path)) return;
|
if (!path || Array.isArray(path)) return;
|
||||||
@@ -1427,6 +1602,16 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
|
const isFind =
|
||||||
|
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f";
|
||||||
|
if (isFind && !settingsOpen && !confirmState) {
|
||||||
|
const opened = openDocumentSearch();
|
||||||
|
if (opened) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isSave =
|
const isSave =
|
||||||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
||||||
if (isSave) {
|
if (isSave) {
|
||||||
@@ -1442,7 +1627,7 @@ export default function App() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [confirmState, editingFolderId, editingTextId, handleSaveVersion, selectedTextId, settingsOpen]);
|
}, [confirmState, handleSaveVersion, openDocumentSearch, settingsOpen]);
|
||||||
|
|
||||||
const renderTextItem = (text: Text) => (
|
const renderTextItem = (text: Text) => (
|
||||||
<div
|
<div
|
||||||
@@ -1772,36 +1957,38 @@ export default function App() {
|
|||||||
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
|
<img src={sidebarExpandIconSrc} alt="" className="icon-button__img" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{hasText ? (
|
{hasText || isConverting ? (
|
||||||
<>
|
<>
|
||||||
|
{hasText ? (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMarkdownPreview((value) => !value)}
|
||||||
|
>
|
||||||
|
{markdownPreview
|
||||||
|
? splitView
|
||||||
|
? "Hide Preview"
|
||||||
|
: "Edit"
|
||||||
|
: "Preview Markdown"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMarkdownPreview((value) => !value)}
|
onClick={isConverting ? handleCancelConversion : handleConvertToMarkdown}
|
||||||
|
disabled={isConverting ? false : !ollamaModel || isViewingHistory || !hasText}
|
||||||
>
|
>
|
||||||
{markdownPreview
|
{isConverting ? "Cancel Conversion" : "Convert to Markdown"}
|
||||||
? splitView
|
|
||||||
? "Hide Preview"
|
|
||||||
: "Edit"
|
|
||||||
: "Preview Markdown"}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
{hasText ? (
|
||||||
className="button"
|
<button className="button" onClick={handleExportText}>
|
||||||
type="button"
|
Export Text
|
||||||
onClick={handleConvertToMarkdown}
|
</button>
|
||||||
disabled={!ollamaModel || isConverting || isViewingHistory}
|
) : null}
|
||||||
>
|
{hasText && markdownPreview ? (
|
||||||
{isConverting ? "Converting…" : "Convert to Markdown"}
|
<button className="button" type="button" onClick={handlePrintMarkdown}>
|
||||||
</button>
|
Print
|
||||||
<button className="button" onClick={handleExportText}>
|
</button>
|
||||||
Export Text
|
|
||||||
</button>
|
|
||||||
{markdownPreview ? (
|
|
||||||
<>
|
|
||||||
<button className="button" type="button" onClick={handlePrintMarkdown}>
|
|
||||||
Print
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1825,6 +2012,15 @@ export default function App() {
|
|||||||
<span className={`status status--${statusKey}`}></span>
|
<span className={`status status--${statusKey}`}></span>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</div>
|
</div>
|
||||||
|
{conversionLabel ? (
|
||||||
|
<div className="status-line status-line--secondary">{conversionLabel}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="editor__stats" aria-label="Document statistics">
|
||||||
|
<span>{documentStats.characters} chars</span>
|
||||||
|
<span>{documentStats.words} words</span>
|
||||||
|
<span>{documentStats.sentences} sentences</span>
|
||||||
|
<span>{documentStats.estimatedTokens} est. tokens</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button button--primary button--save"
|
className="button button--primary button--save"
|
||||||
|
|||||||
115
src/index.css
115
src/index.css
@@ -504,6 +504,11 @@ body:not([data-theme="light"]) .folder-item {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-line--secondary {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -627,6 +632,15 @@ body:not([data-theme="light"]) .folder-item {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-cursor,
|
||||||
|
.editor__codemirror .cm-dropCursor {
|
||||||
|
border-left-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-fatCursor {
|
||||||
|
background: rgba(245, 245, 245, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.editor__codemirror .cm-scroller {
|
.editor__codemirror .cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -649,6 +663,76 @@ body:not([data-theme="light"]) .folder-item {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-panels {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-panels-top {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search input,
|
||||||
|
.editor__codemirror .cm-search button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search input[type="text"] {
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search button {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search button:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search [name="close"] {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-search input[type="checkbox"] {
|
||||||
|
accent-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-searchMatch {
|
||||||
|
background: rgba(214, 214, 96, 0.28);
|
||||||
|
outline: 1px solid rgba(214, 214, 96, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
|
||||||
|
background: rgba(233, 216, 112, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-preview {
|
.markdown-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -670,6 +754,19 @@ body:not([data-theme="light"]) .folder-item {
|
|||||||
|
|
||||||
.editor__footer-status {
|
.editor__footer-status {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
@@ -1029,6 +1126,24 @@ body[data-theme="light"] .editor__textarea-wrap:focus-within {
|
|||||||
border-color: var(--accent-strong);
|
border-color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .editor__codemirror .cm-cursor,
|
||||||
|
body[data-theme="light"] .editor__codemirror .cm-dropCursor {
|
||||||
|
border-left-color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .editor__codemirror .cm-fatCursor {
|
||||||
|
background: rgba(31, 31, 31, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .editor__codemirror .cm-searchMatch {
|
||||||
|
background: rgba(219, 199, 81, 0.3);
|
||||||
|
outline-color: rgba(170, 141, 33, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
|
||||||
|
background: rgba(219, 199, 81, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-panel__section--row {
|
.settings-panel__section--row {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -179,15 +179,55 @@ export function markdownToHTML(text) {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, (_, label, href) => {
|
const renderExternalLink = (label, hrefRaw) => {
|
||||||
const url = safeLink(href);
|
const url = safeLink(hrefRaw);
|
||||||
const tooltip = escapeHtml(href || '');
|
const tooltip = escapeHtml(hrefRaw || '');
|
||||||
if (!url) return label;
|
if (!url) return label;
|
||||||
return `<a class="md-link md-link--external" href="${escapeAttr(
|
return `<a class="md-link md-link--external" href="${escapeAttr(
|
||||||
url
|
url
|
||||||
)}" target="_blank" rel="noreferrer noopener"><span class="md-link__label">${label}</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md-icon md-icon-external"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="md-link__tooltip">${tooltip}</span></a>`;
|
)}" target="_blank" rel="noreferrer noopener"><span class="md-link__label">${label}</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md-icon md-icon-external"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="md-link__tooltip">${tooltip}</span></a>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, (_, label, href) => {
|
||||||
|
return renderExternalLink(label, href);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const autoLinkPlainUrls = (source) => {
|
||||||
|
const protectedChunkRe = /(<a\b[\s\S]*?<\/a>|<code>[\s\S]*?<\/code>|@@CODEBLOCK\d+@@)/g;
|
||||||
|
const isProtectedChunk = /^(<a\b[\s\S]*<\/a>|<code>[\s\S]*<\/code>|@@CODEBLOCK\d+@@)$/;
|
||||||
|
const trimTrailingPunctuation = (value) => {
|
||||||
|
let linked = value;
|
||||||
|
let trailing = '';
|
||||||
|
while (linked.length > 0) {
|
||||||
|
const last = linked[linked.length - 1];
|
||||||
|
if (!/[)\].,!?;:]/.test(last)) break;
|
||||||
|
if (last === ')') {
|
||||||
|
const opens = (linked.match(/\(/g) || []).length;
|
||||||
|
const closes = (linked.match(/\)/g) || []).length;
|
||||||
|
if (closes <= opens) break;
|
||||||
|
}
|
||||||
|
linked = linked.slice(0, -1);
|
||||||
|
trailing = last + trailing;
|
||||||
|
}
|
||||||
|
return { linked, trailing };
|
||||||
|
};
|
||||||
|
|
||||||
|
return source
|
||||||
|
.split(protectedChunkRe)
|
||||||
|
.map((part) => {
|
||||||
|
if (!part || isProtectedChunk.test(part)) return part;
|
||||||
|
return part.replace(/(^|[\s(>])((?:https?:\/\/|www\.)[^\s<]+)/g, (_, lead, rawUrl) => {
|
||||||
|
const { linked, trailing } = trimTrailingPunctuation(rawUrl);
|
||||||
|
if (!linked) return `${lead}${rawUrl}`;
|
||||||
|
const href = /^www\./i.test(linked) ? `https://${linked}` : linked;
|
||||||
|
return `${lead}${renderExternalLink(linked, href)}${trailing}`;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
html = autoLinkPlainUrls(html);
|
||||||
|
|
||||||
// 6) Convert line-breaks to HTML paragraphs and <br /> inside paragraphs
|
// 6) Convert line-breaks to HTML paragraphs and <br /> inside paragraphs
|
||||||
const linesWithHtml = html.split("\n");
|
const linesWithHtml = html.split("\n");
|
||||||
const htmlLines = [];
|
const htmlLines = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user