Compare commits
51 Commits
c12d7d03a5
...
f5bc0dbe69
| Author | SHA1 | Date | |
|---|---|---|---|
| f5bc0dbe69 | |||
| b8def8d0ab | |||
| 0c68bfffe2 | |||
| f7175fc722 | |||
| 4bba163855 | |||
| 788b312976 | |||
| 45c9221fe9 | |||
| 6aff8091e3 | |||
| 90d32279c3 | |||
| 15993ed33c | |||
| 4ae7f82288 | |||
| c2a6035f3d | |||
| fa8d2807e8 | |||
| 03deb8de1c | |||
| b8d93c9ddf | |||
| 555c08fd0b | |||
| acb084dae9 | |||
| eccd378043 | |||
| 05ef4ba130 | |||
| c0293ac021 | |||
| 735cc89e72 | |||
| 8cf0ec950a | |||
| 1cd0d58206 | |||
| 3d3b230125 | |||
| 5855de1a2f | |||
| b6187d1338 | |||
| 15f1b22f3b | |||
| afb7aac60e | |||
| 4a40ffc30c | |||
| 0c727df84e | |||
| 6601d2ef41 | |||
| c9243c77d2 | |||
| 6a5a2a38b1 | |||
| fd6be4c6df | |||
| 6bafb309f6 | |||
| 158f9afb5e | |||
| 09b395172e | |||
| 7b1066605f | |||
| 9f04d11664 | |||
| 9fa2b8c663 | |||
| 8e4322550b | |||
| a56a7ee647 | |||
| e1cb7a60e5 | |||
| 49da9fccc4 | |||
| ec5bc01ab6 | |||
| fed15761ad | |||
| 749fea672c | |||
| bd6ffe6d41 | |||
| 700b9d72bb | |||
| b6f6ca8e1b | |||
| 3114b936f1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,4 +11,5 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/gen/Cargo.lock
|
||||
*.lock
|
||||
|
||||
13
README.md
13
README.md
@@ -1,6 +1,6 @@
|
||||
# TextDB
|
||||
|
||||
TextDB is a **non-destructive**, completely offline, local-first text editor. Its core promise is simple: your edits never overwrite your history. Every manual save is immutable and append-only, and autosaves are kept as a separate draft layer. No accounts, no telemetry, no remote fonts, and no network calls.
|
||||
TextDB is a **non-destructive**, completely offline, local-first text editor with built-in Markdown preview functionality. Its core promise is simple: your edits never overwrite your history. Every manual save is immutable and append-only, and autosaves are kept as a separate draft layer. No accounts, no telemetry, no remote fonts, and no network calls.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -8,12 +8,16 @@ TextDB is a **non-destructive**, completely offline, local-first text editor. It
|
||||
- Sidebar list of texts with live search (titles + version bodies).
|
||||
- Editor with title, content area, and clear save status.
|
||||
- Cmd/Ctrl+S creates a new immutable manual version.
|
||||
- Markdown preview mode with external link support and printable output.
|
||||
- Pressing Tab toggles Edit/Preview mode.
|
||||
- Autosave draft (debounced) that can be safely discarded.
|
||||
- History panel listing versions by timestamp (drafts included when present).
|
||||
- Settings: theme (default/bright), text size slider, and optional line numbers.
|
||||
- Collapsible sidebar with persistent state.
|
||||
- Export the current text to a `.txt` file.
|
||||
- Open `.txt` files to create new entries from file content.
|
||||
- Drag and drop `.txt` files onto the window to import them.
|
||||
- Registers `.txt` file association for using TextDB as a default editor.
|
||||
- Open `.txt` or `.md` files to create new entries from file content.
|
||||
- Drag and drop `.txt` or `.md` files onto the window to import them.
|
||||
- Registers `.txt` and `.md` file associations for using TextDB as a default editor.
|
||||
|
||||
## Use cases
|
||||
|
||||
@@ -55,3 +59,4 @@ npm run tauri dev
|
||||
- SQLite is loaded via `Database.load("sqlite:text.db")`.
|
||||
- The database file is stored in the Tauri app data directory.
|
||||
- The app is fully offline by design: no telemetry, no external fonts, no CDNs.
|
||||
- UI settings (theme, text size, line numbers, sidebar state, last selected text) are stored in localStorage.
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-sql": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
497
src-tauri/Cargo.lock
generated
497
src-tauri/Cargo.lock
generated
@@ -53,6 +53,27 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -187,6 +208,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
@@ -318,6 +345,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -456,6 +492,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -663,6 +705,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -728,6 +776,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -745,6 +802,22 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "etcetera"
|
||||
version = "0.8.0"
|
||||
@@ -767,6 +840,26 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||
dependencies = [
|
||||
"fax_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax_derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -792,6 +885,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.8"
|
||||
@@ -1073,6 +1172,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -1255,6 +1364,17 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1459,7 +1579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1570,6 +1690,20 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.0",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1618,6 +1752,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1801,6 +1954,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1911,6 +2070,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -1926,7 +2095,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -1974,6 +2143,15 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -2266,12 +2444,34 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2326,6 +2526,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -2341,6 +2547,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -2540,6 +2757,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2638,6 +2868,21 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -2915,6 +3160,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3213,12 +3471,54 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_child"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"sigchld",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"os_pipe",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
@@ -3792,7 +4092,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -3839,6 +4139,21 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-clipboard-manager"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
@@ -3879,6 +4194,27 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39b76f884a3937e04b631ffdc3be506088fa979369d25147361352f2f352e5ed"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"open",
|
||||
"os_pipe",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared_child",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-sql"
|
||||
version = "2.3.1"
|
||||
@@ -4018,8 +4354,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-sql",
|
||||
]
|
||||
|
||||
@@ -4063,6 +4401,20 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.46"
|
||||
@@ -4346,12 +4698,23 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -4649,6 +5012,76 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
@@ -4739,6 +5172,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
@@ -5274,6 +5713,24 @@ version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
@@ -5346,6 +5803,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -5454,3 +5928,18 @@ name = "zmij"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,8 @@ tauri-build = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-clipboard-manager = { version = "2" }
|
||||
tauri-plugin-dialog = { version = "2" }
|
||||
tauri-plugin-fs = { version = "2" }
|
||||
tauri-plugin-shell = { version = "2" }
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:webview:allow-print",
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"shell:allow-open",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-read-file",
|
||||
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"main":{"identifier":"main","description":"Default capabilities for TextDB","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-print","sql:default","sql:allow-execute","dialog:default","clipboard-manager:allow-write-text","shell:allow-open","fs:default","fs:allow-read-text-file","fs:allow-read-file","fs:allow-read","fs:allow-home-read-recursive","fs:allow-download-read-recursive","fs:allow-document-read-recursive","fs:allow-desktop-read-recursive","fs:allow-write-file","fs:allow-write-text-file","fs:allow-home-write-recursive"]}}
|
||||
6290
src-tauri/gen/schemas/desktop-schema.json
Normal file
6290
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
6290
src-tauri/gen/schemas/macOS-schema.json
Normal file
6290
src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,9 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![take_pending_opens])
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<Wry, ()>::new("file-open")
|
||||
|
||||
@@ -60,6 +60,20 @@
|
||||
"contentTypes": [
|
||||
"public.plain-text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ext": [
|
||||
"md"
|
||||
],
|
||||
"name": "Markdown Document",
|
||||
"description": "Markdown document",
|
||||
"mimeType": "text/markdown",
|
||||
"role": "Editor",
|
||||
"rank": "Default",
|
||||
"contentTypes": [
|
||||
"net.daringfireball.markdown",
|
||||
"public.plain-text"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
189
src/App.tsx
189
src/App.tsx
@@ -1,5 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
||||
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { appDataDir } from "@tauri-apps/api/path";
|
||||
@@ -7,6 +9,8 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import historyIcon from "./assets/history.png";
|
||||
import historyIconBright from "./assets/history_b.png";
|
||||
import { markdownToHTML } from "./markdown/markdown";
|
||||
import "./markdown/markdown-render.css";
|
||||
import {
|
||||
createText,
|
||||
deleteText,
|
||||
@@ -77,6 +81,7 @@ export default function App() {
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState<string | null>(null);
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
const [theme, setTheme] = useState<"default" | "light">(() => {
|
||||
const storedTheme = localStorage.getItem("textdb.theme");
|
||||
return storedTheme === "light" ? "light" : "default";
|
||||
@@ -137,6 +142,46 @@ export default function App() {
|
||||
const isViewingHistory = viewingVersion !== null;
|
||||
const isDirty = !isViewingHistory && body !== lastPersistedBody;
|
||||
const hasText = body.trim().length > 0;
|
||||
const showLineNumbersActive = showLineNumbers && !markdownPreview;
|
||||
|
||||
const handleMarkdownPreviewClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const copyButton = target?.closest?.(".md-codeblock__copy") as HTMLElement | null;
|
||||
if (copyButton) {
|
||||
event.preventDefault();
|
||||
const encoded = copyButton.getAttribute("data-copy-code") ?? "";
|
||||
const text = decodeURIComponent(encoded);
|
||||
if (!text) return;
|
||||
writeClipboardText(text).catch(() => {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const link = target?.closest?.("a");
|
||||
if (!link) return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#") || href.startsWith("/")) return;
|
||||
event.preventDefault();
|
||||
openExternal(href);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePrintMarkdown = useCallback(() => {
|
||||
if (!markdownPreview) return;
|
||||
document.body.classList.add("print-markdown");
|
||||
const cleanup = () => {
|
||||
document.body.classList.remove("print-markdown");
|
||||
window.removeEventListener("afterprint", cleanup);
|
||||
};
|
||||
window.addEventListener("afterprint", cleanup);
|
||||
requestAnimationFrame(() => {
|
||||
window.print();
|
||||
});
|
||||
}, [markdownPreview]);
|
||||
|
||||
const statusKey = useMemo(() => {
|
||||
if (isViewingHistory) return "history";
|
||||
@@ -166,14 +211,14 @@ export default function App() {
|
||||
const lineNumbers = useMemo(() => lines.map((_, index) => index + 1), [lines]);
|
||||
|
||||
const handleTextareaScroll = useCallback((event: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!showLineNumbers) return;
|
||||
if (!showLineNumbersActive) return;
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = event.currentTarget.scrollTop;
|
||||
}
|
||||
}, [showLineNumbers]);
|
||||
}, [showLineNumbersActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLineNumbers) return;
|
||||
if (!showLineNumbersActive) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
@@ -181,10 +226,10 @@ export default function App() {
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}, [showLineNumbers]);
|
||||
}, [showLineNumbersActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLineNumbers) return;
|
||||
if (!showLineNumbersActive) return;
|
||||
let raf = 0;
|
||||
const handleResize = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
@@ -197,10 +242,10 @@ export default function App() {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [showLineNumbers]);
|
||||
}, [showLineNumbersActive]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showLineNumbers) return;
|
||||
if (!showLineNumbersActive) return;
|
||||
const textarea = textareaRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!textarea || !measure) return;
|
||||
@@ -212,13 +257,13 @@ export default function App() {
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textarea.scrollTop;
|
||||
}
|
||||
}, [lines, showLineNumbers, textSize, measureTick, sidebarCollapsed, historyOpen]);
|
||||
}, [lines, showLineNumbersActive, textSize, measureTick, sidebarCollapsed, historyOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showLineNumbers && textareaRef.current && lineNumbersRef.current) {
|
||||
if (showLineNumbersActive && textareaRef.current && lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
}, [showLineNumbers, body]);
|
||||
}, [showLineNumbersActive, body]);
|
||||
|
||||
|
||||
const refreshTexts = useCallback(async () => {
|
||||
@@ -299,6 +344,7 @@ export default function App() {
|
||||
setDraftBaseVersionId(null);
|
||||
setViewingVersion(null);
|
||||
setSelectedHistoryId(null);
|
||||
setMarkdownPreview(false);
|
||||
historySnapshotRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -335,6 +381,7 @@ export default function App() {
|
||||
setSelectedHistoryId(
|
||||
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
|
||||
);
|
||||
setMarkdownPreview(false);
|
||||
historySnapshotRef.current = null;
|
||||
};
|
||||
|
||||
@@ -390,7 +437,7 @@ export default function App() {
|
||||
async (filePath: string) => {
|
||||
try {
|
||||
const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE;
|
||||
const title = filename.replace(/\.txt$/i, "") || DEFAULT_TITLE;
|
||||
const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE;
|
||||
const contents = await readTextFile(filePath);
|
||||
const { textId } = await createText(title, contents);
|
||||
await refreshTexts();
|
||||
@@ -405,7 +452,10 @@ export default function App() {
|
||||
const handleFilePaths = useCallback(
|
||||
async (paths: string[]) => {
|
||||
const now = Date.now();
|
||||
const txtPaths = paths.filter((path) => path.toLowerCase().endsWith(".txt"));
|
||||
const txtPaths = paths.filter((path) => {
|
||||
const lower = path.toLowerCase();
|
||||
return lower.endsWith(".txt") || lower.endsWith(".md");
|
||||
});
|
||||
const recent = recentOpenRef.current;
|
||||
for (const path of txtPaths) {
|
||||
const key = path.toLowerCase();
|
||||
@@ -467,7 +517,7 @@ export default function App() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{ name: "Text", extensions: ["txt"] }],
|
||||
filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }],
|
||||
defaultPath: baseDir
|
||||
});
|
||||
if (!path || Array.isArray(path)) return;
|
||||
@@ -722,16 +772,31 @@ export default function App() {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isSave =
|
||||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
||||
if (!isSave) return;
|
||||
event.preventDefault();
|
||||
handleSaveVersion().catch((error) => {
|
||||
console.error("Failed to save version", error);
|
||||
});
|
||||
if (isSave) {
|
||||
event.preventDefault();
|
||||
handleSaveVersion().catch((error) => {
|
||||
console.error("Failed to save version", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Tab" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
selectedTextId &&
|
||||
!settingsOpen &&
|
||||
!confirmState
|
||||
) {
|
||||
event.preventDefault();
|
||||
setMarkdownPreview((value) => !value);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleSaveVersion]);
|
||||
}, [confirmState, handleSaveVersion, selectedTextId, settingsOpen]);
|
||||
|
||||
return (
|
||||
<div className={`app app--theme-${theme}${sidebarCollapsed ? " app--sidebar-collapsed" : ""}`}>
|
||||
@@ -861,19 +926,15 @@ export default function App() {
|
||||
>
|
||||
<section className="editor">
|
||||
<div className="editor__header">
|
||||
<input
|
||||
className="title-input"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
placeholder="Text title"
|
||||
disabled={isViewingHistory}
|
||||
/>
|
||||
<div className="editor__status-row">
|
||||
<div className="status-line">
|
||||
<span className={`status status--${statusKey}`}></span>
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div className="editor__title-row">
|
||||
<input
|
||||
className="title-input"
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
placeholder="Text title"
|
||||
disabled={isViewingHistory}
|
||||
/>
|
||||
<button
|
||||
className={`icon-button${historyOpen ? " is-active" : ""}`}
|
||||
onClick={handleToggleHistory}
|
||||
@@ -886,8 +947,12 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor__textarea-wrap">
|
||||
{showLineNumbers ? (
|
||||
<div
|
||||
className={`editor__textarea-wrap${
|
||||
markdownPreview ? " editor__textarea-wrap--preview" : ""
|
||||
}`}
|
||||
>
|
||||
{showLineNumbersActive ? (
|
||||
<div className="line-measure" ref={measureRef} aria-hidden="true">
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="line-measure__line">
|
||||
@@ -896,7 +961,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showLineNumbers ? (
|
||||
{showLineNumbersActive ? (
|
||||
<div className="line-numbers" ref={lineNumbersRef}>
|
||||
{lineNumbers.map((line, index) => (
|
||||
<div
|
||||
@@ -909,22 +974,46 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor__textarea"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
placeholder="Write your text here…"
|
||||
readOnly={isViewingHistory}
|
||||
/>
|
||||
{markdownPreview ? (
|
||||
<div
|
||||
className="markdown-preview md-root"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHTML(body) }}
|
||||
onClick={handleMarkdownPreviewClick}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor__textarea"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
placeholder="Write your text here…"
|
||||
readOnly={isViewingHistory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="editor__footer">
|
||||
{hasText ? (
|
||||
<button className="button" onClick={handleExportText}>
|
||||
Export Text
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => setMarkdownPreview((value) => !value)}
|
||||
>
|
||||
{markdownPreview ? "Edit" : "Preview Markdown"}
|
||||
</button>
|
||||
<button className="button" onClick={handleExportText}>
|
||||
Export Text
|
||||
</button>
|
||||
{markdownPreview ? (
|
||||
<>
|
||||
<button className="button" type="button" onClick={handlePrintMarkdown}>
|
||||
Print
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{hasDraft && !isViewingHistory ? (
|
||||
<button
|
||||
@@ -941,6 +1030,12 @@ export default function App() {
|
||||
Discard Draft
|
||||
</button>
|
||||
) : null}
|
||||
<div className="editor__footer-status">
|
||||
<div className="status-line">
|
||||
<span className={`status status--${statusKey}`}></span>
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="button button--primary button--save"
|
||||
onClick={handleSaveVersion}
|
||||
|
||||
@@ -178,7 +178,6 @@ body {
|
||||
}
|
||||
|
||||
.button--save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
@@ -319,13 +318,16 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor__status-row {
|
||||
.editor__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor__title-row .title-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-size: 1.6rem;
|
||||
border: none;
|
||||
@@ -431,6 +433,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor__textarea-wrap--preview {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.editor__textarea-wrap:focus-within {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
@@ -494,6 +500,14 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.editor__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -501,6 +515,10 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor__footer-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -839,3 +857,54 @@ body[data-theme="light"] .editor__textarea-wrap:focus-within {
|
||||
.settings-panel__section--row .settings-panel__label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body.print-markdown {
|
||||
--bg: #ffffff;
|
||||
--bg-elevated: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--ink: #111111;
|
||||
--muted: #444444;
|
||||
--border: #dddddd;
|
||||
--bg-input: #ffffff;
|
||||
--bg-contrast: #f5f5f5;
|
||||
--bg-contrast-strong: #eeeeee;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
body.print-markdown .app {
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
body.print-markdown .sidebar,
|
||||
body.print-markdown .history,
|
||||
body.print-markdown .editor__header,
|
||||
body.print-markdown .editor__footer,
|
||||
body.print-markdown .line-numbers,
|
||||
body.print-markdown .line-measure {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.print-markdown .workspace__content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
body.print-markdown .editor__textarea-wrap {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body.print-markdown .markdown-preview {
|
||||
padding: 32px 36px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body.print-markdown .md-codeblock__copy,
|
||||
body.print-markdown .md-icon-external {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
282
src/markdown/markdown-render.css
Normal file
282
src/markdown/markdown-render.css
Normal file
@@ -0,0 +1,282 @@
|
||||
/* Scoped tokens to avoid bleeding into the app */
|
||||
.md-root {
|
||||
--md-border-color: var(--border);
|
||||
--md-surface: var(--bg-contrast);
|
||||
--md-surface-alt: var(--bg-input);
|
||||
--md-radius: 12px;
|
||||
|
||||
--md-text-color: var(--ink);
|
||||
--md-muted-color: var(--muted);
|
||||
--md-link-color: #5aa2ff;
|
||||
--md-link-hover-color: #7bb6ff;
|
||||
|
||||
--md-code-bg: var(--bg-contrast);
|
||||
--md-code-inline-bg: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--md-code-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .md-root {
|
||||
--md-link-color: #0a66c2;
|
||||
--md-link-hover-color: #004182;
|
||||
--md-code-inline-bg: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Optional wrapper if you choose to wrap markdown output:
|
||||
<div class="md-root" v-html="markdownToHTML(text)"> */
|
||||
.md-root {
|
||||
color: var(--md-text-color);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.md-heading {
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--md-text-color);
|
||||
}
|
||||
|
||||
.md-root > .md-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md-heading--1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.md-heading--2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.md-heading--3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.md-heading--4 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.md-paragraph {
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.md-paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.md-hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--md-border-color);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.md-list {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.md-list--unordered {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.md-list--ordered {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.md-list__item {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.md-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--md-border-color);
|
||||
border-radius: var(--md-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-table thead {
|
||||
background: var(--md-surface);
|
||||
}
|
||||
|
||||
.md-table__row {
|
||||
}
|
||||
|
||||
.md-table__row--head {
|
||||
}
|
||||
|
||||
.md-table__head-cell,
|
||||
.md-table__cell {
|
||||
padding: 0.6rem 0.75rem;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--md-border-color);
|
||||
}
|
||||
|
||||
.md-table__row:last-child .md-table__cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.md-align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.md-blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid var(--md-border-color);
|
||||
background: var(--md-surface);
|
||||
color: var(--md-muted-color);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.md-codeblock {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--md-border-color);
|
||||
border-radius: var(--md-radius);
|
||||
overflow: hidden;
|
||||
background: var(--md-surface-alt);
|
||||
}
|
||||
|
||||
.md-codeblock__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--md-surface);
|
||||
border-bottom: 1px solid var(--md-border-color);
|
||||
}
|
||||
|
||||
.md-codeblock__lang {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--md-text-color);
|
||||
}
|
||||
|
||||
.md-codeblock__copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--md-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--md-surface-alt);
|
||||
color: var(--md-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-codeblock__copy:hover {
|
||||
background: var(--md-surface);
|
||||
}
|
||||
|
||||
.md-codeblock__copy:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.md-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Could be specialized later if needed */
|
||||
.md-icon-external {
|
||||
}
|
||||
|
||||
.md-icon-copy {
|
||||
}
|
||||
|
||||
.md-codeblock__pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
background: var(--md-code-bg);
|
||||
}
|
||||
|
||||
.md-codeblock__code {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
font-family: var(--md-code-font);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Inline code (outside of fenced blocks) */
|
||||
code:not(.md-codeblock__code) {
|
||||
font-family: var(--md-code-font);
|
||||
font-size: 0.95em;
|
||||
padding: 0.1em 0.25em;
|
||||
border-radius: 4px;
|
||||
background: var(--md-code-inline-bg);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.md-link {
|
||||
color: var(--md-link-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.md-link:hover,
|
||||
.md-link:focus-visible {
|
||||
color: var(--md-link-hover-color);
|
||||
}
|
||||
|
||||
.md-link--external {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.md-link__label {
|
||||
/* can be customized if needed */
|
||||
}
|
||||
|
||||
.md-link__tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-contrast-strong);
|
||||
color: var(--md-text-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.md-link--external:hover .md-link__tooltip,
|
||||
.md-link--external:focus-visible .md-link__tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
305
src/markdown/markdown.js
Normal file
305
src/markdown/markdown.js
Normal file
@@ -0,0 +1,305 @@
|
||||
export function markdownToHTML(text) {
|
||||
// 0) Remove <think>...</think>/<thinking>...</thinking> blocks
|
||||
text = text.replace(/<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, '');
|
||||
|
||||
// Normalize exotic spaces (narrow/non-breaking) to regular spaces
|
||||
text = text.replace(/[\u00a0\u202f\u2007]/g, ' ');
|
||||
|
||||
text = balanceStreamingCodeFence(text);
|
||||
|
||||
const escapeHtml = (value = '') =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const escapeAttr = (value = '') =>
|
||||
escapeHtml(value)
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const applyInline = (source) => {
|
||||
const codeRuns = [];
|
||||
let tmp = source.replace(/`([^`]+?)`/g, (_, code) => {
|
||||
const idx = codeRuns.push(code) - 1;
|
||||
return `@@CODEINLINE${idx}@@`;
|
||||
});
|
||||
|
||||
const strongRuns = [];
|
||||
tmp = tmp.replace(/\*\*([\s\S]+?)\*\*/g, (_, content) => {
|
||||
const idx = strongRuns.push(content) - 1;
|
||||
return `@@STRONG${idx}@@`;
|
||||
});
|
||||
|
||||
const emphasisRuns = [];
|
||||
tmp = tmp.replace(/(?<!\*)\*([\s\S]+?)\*(?!\*)/g, (_, content) => {
|
||||
const idx = emphasisRuns.push(content) - 1;
|
||||
return `@@EM${idx}@@`;
|
||||
});
|
||||
|
||||
return tmp
|
||||
.replace(/@@STRONG(\d+)@@/g, (_, idx) => `<b>${strongRuns[+idx]}</b>`)
|
||||
.replace(/@@EM(\d+)@@/g, (_, idx) => `<i>${emphasisRuns[+idx]}</i>`)
|
||||
.replace(/@@CODEINLINE(\d+)@@/g, (_, idx) => `<code>${codeRuns[+idx]}</code>`);
|
||||
};
|
||||
|
||||
// 1) Normalize line endings
|
||||
let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// 2) Extract code blocks and replace with placeholders (protect from all formatting)
|
||||
const codeblocks = [];
|
||||
const placeholder = (idx) => `@@CODEBLOCK${idx}@@`;
|
||||
|
||||
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
let cleaned = (code || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const lines = cleaned.split('\n');
|
||||
while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1])) lines.pop();
|
||||
cleaned = lines.join('\n');
|
||||
codeblocks.push({ lang: (lang || '').trim(), code: cleaned });
|
||||
return placeholder(codeblocks.length - 1);
|
||||
});
|
||||
|
||||
// 3) HTML-escape special characters (outside of fenced code blocks)
|
||||
let escaped = escapeHtml(tmp);
|
||||
|
||||
// 4) Headings (with consistent hooks)
|
||||
escaped = escaped
|
||||
.replace(/^#### (.+)$/gm, '<h4 class="md-heading md-heading--4">$1</h4>')
|
||||
.replace(/^### (.+)$/gm, '<h3 class="md-heading md-heading--3">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="md-heading md-heading--2">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="md-heading md-heading--1">$1</h1>');
|
||||
|
||||
// 4.1) Horizontal rules: --- or *** or ___ on a line
|
||||
escaped = escaped.replace(/^(?:-{3,}|\*{3,}|_{3,})\s*$/gm, '<hr class="md-hr">');
|
||||
|
||||
// 4.2) Ordered lists: lines starting with "1. ", "2. ", ...
|
||||
escaped = escaped.replace(
|
||||
/(^|\n)([ \t]*\d+\. .+(?:\n[ \t]*\d+\. .+)*)/g,
|
||||
(_, lead, listBlock) => {
|
||||
const items = listBlock
|
||||
.split(/\n/)
|
||||
.map((line) => line.replace(/^[ \t]*\d+\.\s+/, '').trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => `<li class="md-list__item">${item}</li>`)
|
||||
.join('');
|
||||
if (!items) return listBlock;
|
||||
return `${lead}<ol class="md-list md-list--ordered">${items}</ol>`;
|
||||
}
|
||||
);
|
||||
|
||||
// 4.3) Blockquotes
|
||||
escaped = escaped.replace(
|
||||
/(^|\n)([ \t]*> .+(?:\n[ \t]*> .+)*)/g,
|
||||
(_, lead, blockquoteBlock) => {
|
||||
const lines = blockquoteBlock
|
||||
.split(/\n/)
|
||||
.map((line) => line.replace(/^[ \t]*>\s*/, '').trim())
|
||||
.join('<br />');
|
||||
return `${lead}<blockquote class="md-blockquote">${lines}</blockquote>`;
|
||||
}
|
||||
);
|
||||
|
||||
// 4.5) Unordered lists
|
||||
escaped = escaped.replace(
|
||||
/(^|\n)([ \t]*[-*] .+(?:\n[ \t]*[-*] .+)*)/g,
|
||||
(_, lead, listBlock) => {
|
||||
const items = listBlock
|
||||
.split(/\n/)
|
||||
.map((line) => line.replace(/^[ \t]*[-*]\s+/, '').trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => `<li class="md-list__item">${item}</li>`)
|
||||
.join('');
|
||||
if (!items) return listBlock;
|
||||
return `${lead}<ul class="md-list md-list--unordered">${items}</ul>`;
|
||||
}
|
||||
);
|
||||
|
||||
// 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
|
||||
const mdTableBlockRe =
|
||||
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
|
||||
|
||||
escaped = escaped.replace(mdTableBlockRe, (block) => {
|
||||
const hadTrailingNewline = /\n$/.test(block);
|
||||
const lines = block.replace(/\n$/, '').split('\n');
|
||||
|
||||
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
|
||||
|
||||
const headers = split(lines[0]);
|
||||
const seps = split(lines[1]);
|
||||
if (headers.length < 2 || seps.length < 2) return block;
|
||||
if (!seps.every((s) => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
|
||||
|
||||
const aligns = seps.map((seg) => {
|
||||
const s = seg.replace(/\s+/g, '');
|
||||
const left = s.startsWith(':');
|
||||
const right = s.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
|
||||
const bodyLines = lines.slice(2).filter((l) => /^\|/.test(l.trim()));
|
||||
const alignClass = (i) => `md-align-${aligns[i] || 'left'}`;
|
||||
|
||||
const ths = headers
|
||||
.map(
|
||||
(h, i) =>
|
||||
`<th class="md-table__head-cell ${alignClass(i)}">${h}</th>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const rows = bodyLines
|
||||
.map((line) => {
|
||||
const cells = split(line);
|
||||
const tds = cells
|
||||
.map(
|
||||
(c, i) =>
|
||||
`<td class="md-table__cell ${alignClass(i)}">${c}</td>`
|
||||
)
|
||||
.join('');
|
||||
return `<tr class="md-table__row">${tds}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const table = `<table class="md-table"><thead><tr class="md-table__row md-table__row--head">${ths}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
|
||||
return table + (hadTrailingNewline ? '\n' : '');
|
||||
});
|
||||
|
||||
// 5) Bold, italic, inline code
|
||||
let html = applyInline(escaped);
|
||||
|
||||
// 5.5) Links
|
||||
const safeLink = (hrefRaw) => {
|
||||
const href = (hrefRaw || '').trim();
|
||||
if (!href) return '';
|
||||
if (/^https?:\/\//i.test(href)) return href;
|
||||
if (/^mailto:/i.test(href) || /^tel:/i.test(href)) return href;
|
||||
if (href.startsWith('/') || href.startsWith('#')) return href;
|
||||
return '';
|
||||
};
|
||||
|
||||
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, (_, label, href) => {
|
||||
const url = safeLink(href);
|
||||
const tooltip = escapeHtml(href || '');
|
||||
if (!url) return label;
|
||||
return `<a class="md-link md-link--external" href="${escapeAttr(
|
||||
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>`;
|
||||
});
|
||||
|
||||
// 6) Convert line-breaks to HTML paragraphs and <br /> inside paragraphs
|
||||
const linesWithHtml = html.split("\n");
|
||||
const htmlLines = [];
|
||||
let paragraph = [];
|
||||
let emptyCount = 0;
|
||||
let seenContent = false;
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length === 0) return;
|
||||
htmlLines.push(`<p class="md-paragraph">${paragraph.join("<br />")}</p>`);
|
||||
paragraph = [];
|
||||
seenContent = true;
|
||||
};
|
||||
const pushExtraBreaks = () => {
|
||||
if (!seenContent) {
|
||||
emptyCount = 0;
|
||||
return;
|
||||
}
|
||||
const extraBreaks = Math.max(0, emptyCount - 1);
|
||||
for (let j = 0; j < extraBreaks; j += 1) {
|
||||
htmlLines.push("<br />");
|
||||
}
|
||||
emptyCount = 0;
|
||||
};
|
||||
|
||||
const isBlockLine = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^@@CODEBLOCK\d+@@$/.test(trimmed)) return true;
|
||||
if (!trimmed.startsWith("<")) return false;
|
||||
return /^(<h[1-4]\b|<hr\b|<table\b|<ul\b|<ol\b|<blockquote\b|<div class="md-codeblock"\b|<\/(?:h[1-4]|table|ul|ol|blockquote)>)/.test(
|
||||
trimmed
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < linesWithHtml.length; i += 1) {
|
||||
const line = linesWithHtml[i] ?? "";
|
||||
if (line.trim().length === 0) {
|
||||
if (paragraph.length > 0) {
|
||||
flushParagraph();
|
||||
}
|
||||
emptyCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (isBlockLine(line)) {
|
||||
if (paragraph.length > 0) {
|
||||
flushParagraph();
|
||||
}
|
||||
pushExtraBreaks();
|
||||
htmlLines.push(line);
|
||||
seenContent = true;
|
||||
continue;
|
||||
}
|
||||
if (emptyCount > 0) {
|
||||
pushExtraBreaks();
|
||||
}
|
||||
paragraph.push(line);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
html = htmlLines.join("");
|
||||
|
||||
// 7) Restore code blocks with header + copy button
|
||||
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
|
||||
const { lang, code } = codeblocks[+idx];
|
||||
const title = (lang && lang.trim()) ? lang.trim() : 'code';
|
||||
const titleLabel = escapeHtml(title);
|
||||
const languageClass = title.toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'code';
|
||||
|
||||
const escapedCode = escapeHtml(code);
|
||||
const encodedForCopy = encodeURIComponent(code);
|
||||
|
||||
const head = `<div class="md-codeblock__header"><div class="md-codeblock__lang">${titleLabel}</div><button type="button" class="md-codeblock__copy" aria-label="Copy code" title="Copy code" data-copy-code="${escapeAttr(
|
||||
encodedForCopy
|
||||
)}"><svg class="md-icon md-icon-copy" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg></button></div>`;
|
||||
|
||||
const body = `<pre class="md-codeblock__pre"><code class="md-codeblock__code language-${languageClass}">${escapedCode}</code></pre>`;
|
||||
|
||||
return `<div class="md-codeblock">${head}${body}</div>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Virtually close an unfinished fenced code block so it renders during streaming.
|
||||
function balanceStreamingCodeFence(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
let open = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!open) {
|
||||
const m = line.match(/^\s*([`~]{3,})([^\s]*)?.*$/);
|
||||
if (m) {
|
||||
open = { fenceChar: m[1][0], fenceLen: m[1].length };
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const re = new RegExp(
|
||||
`^\\s*(${open.fenceChar}{${open.fenceLen},})\\s*$`
|
||||
);
|
||||
if (re.test(line)) {
|
||||
open = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
const virtual = `${open.fenceChar.repeat(open.fenceLen)}`;
|
||||
return md.endsWith('\n') ? md + virtual : md + '\n' + virtual;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
Reference in New Issue
Block a user