Compare commits

...

51 Commits

Author SHA1 Message Date
f5bc0dbe69 Update README.md with minor corrections 2026-01-31 17:01:58 +01:00
b8def8d0ab Add Markdown preview to TextDB description in README.md 2026-01-31 17:00:54 +01:00
0c68bfffe2 Update README.md with new features 2026-01-31 17:00:54 +01:00
f7175fc722 Enhance keydown handling in App.tsx 2026-01-31 16:58:34 +01:00
4bba163855 Remove unused 'Export PDF' button in App.tsx 2026-01-31 16:58:30 +01:00
788b312976 Enhance markdownToHTML to handle empty lines and content separation 2026-01-31 16:46:30 +01:00
45c9221fe9 Add margin styles for markdown paragraphs 2026-01-31 16:46:08 +01:00
6aff8091e3 Update markdown.js to convert line breaks to HTML paragraphs and handle block elements correctly 2026-01-31 16:45:04 +01:00
90d32279c3 Simplify and optimize line break handling in markdown.js 2026-01-31 16:44:00 +01:00
15993ed33c Improve line break handling in markdownToHTML function 2026-01-31 16:40:06 +01:00
4ae7f82288 Refactor line break handling in markdown.js 2026-01-31 16:36:44 +01:00
c2a6035f3d Improve markdown to HTML conversion by trimming breaks around code blocks 2026-01-31 16:30:11 +01:00
fa8d2807e8 Improve markdown to HTML conversion by trimming breaks around block elements 2026-01-31 16:30:05 +01:00
03deb8de1c Update markdown.js to handle line breaks and code blocks more effectively 2026-01-31 16:27:28 +01:00
b8d93c9ddf Remove top margin from first markdown heading 2026-01-31 16:26:21 +01:00
555c08fd0b Add padding to markdown preview in print mode 2026-01-31 16:24:01 +01:00
acb084dae9 Hide copy and external icons in print mode 2026-01-31 16:24:01 +01:00
eccd378043 Add 'core:webview:allow-print' permission to capabilities.json 2026-01-31 16:22:21 +01:00
05ef4ba130 Add webview print permission in main.json 2026-01-31 16:21:17 +01:00
c0293ac021 Add print styles for markdown content in index.css 2026-01-31 16:19:06 +01:00
735cc89e72 Add onClick handler to Print button in App.tsx 2026-01-31 16:19:06 +01:00
8cf0ec950a Add print functionality for markdown preview 2026-01-31 16:18:34 +01:00
1cd0d58206 Remove unused CSS properties in index.css 2026-01-31 16:17:51 +01:00
3d3b230125 Reorder and enhance Markdown preview button in editor footer 2026-01-31 16:14:23 +01:00
5855de1a2f Reset markdown preview state on certain conditions 2026-01-31 16:14:23 +01:00
b6187d1338 Update README.md to support .md files 2026-01-31 16:11:38 +01:00
15f1b22f3b Add Markdown file type configuration 2026-01-31 14:41:51 +01:00
afb7aac60e Support Markdown files in App.tsx 2026-01-31 14:41:51 +01:00
4a40ffc30c Update CSS for preview mode in editor 2026-01-31 14:37:26 +01:00
0c727df84e Add conditional class to editor textarea wrap 2026-01-31 14:37:26 +01:00
6601d2ef41 auto-git:
[change] src-tauri/gen/schemas/acl-manifests.json
 [change] src-tauri/gen/schemas/capabilities.json
 [change] src-tauri/gen/schemas/desktop-schema.json
 [change] src-tauri/gen/schemas/macOS-schema.json
 [change] src/App.tsx
2026-01-31 14:35:53 +01:00
c9243c77d2 auto-git:
[change] src-tauri/Cargo.lock
 [change] src-tauri/capabilities/main.json
2026-01-31 14:35:53 +01:00
6a5a2a38b1 auto-git:
[change] src-tauri/src/main.rs
2026-01-31 14:35:53 +01:00
fd6be4c6df auto-git:
[change] src-tauri/Cargo.toml
2026-01-31 14:35:53 +01:00
6bafb309f6 auto-git:
[change] package.json
2026-01-31 14:35:53 +01:00
158f9afb5e Set fixed size for markdown icons 2026-01-31 14:34:58 +01:00
09b395172e Add click event handler to markdown preview 2026-01-31 14:32:30 +01:00
7b1066605f auto-git:
[change] src-tauri/gen/schemas/acl-manifests.json
 [change] src-tauri/gen/schemas/capabilities.json
 [change] src-tauri/gen/schemas/desktop-schema.json
 [change] src-tauri/gen/schemas/macOS-schema.json
 [change] src/App.tsx
2026-01-31 14:31:11 +01:00
9f04d11664 auto-git:
[add] src-tauri/gen/schemas/acl-manifests.json
 [add] src-tauri/gen/schemas/capabilities.json
 [add] src-tauri/gen/schemas/desktop-schema.json
 [add] src-tauri/gen/schemas/macOS-schema.json
 [change] .gitignore
 [change] src-tauri/Cargo.lock
2026-01-31 14:31:08 +01:00
9fa2b8c663 auto-git:
[change] src-tauri/capabilities/main.json
2026-01-31 14:31:08 +01:00
8e4322550b auto-git:
[change] src-tauri/src/main.rs
2026-01-31 14:31:08 +01:00
a56a7ee647 auto-git:
[change] src-tauri/Cargo.toml
2026-01-31 14:31:08 +01:00
e1cb7a60e5 auto-git:
[change] package.json
2026-01-31 14:31:08 +01:00
49da9fccc4 Update markdown-render.css with scoped tokens and theme support 2026-01-31 14:25:28 +01:00
ec5bc01ab6 Update markdown.js to handle links correctly 2026-01-31 14:22:33 +01:00
fed15761ad Add styles for markdown preview in index.css 2026-01-31 14:21:09 +01:00
749fea672c Add markdown preview and export options in App component 2026-01-31 14:20:03 +01:00
bd6ffe6d41 Add markdown preview feature to App.tsx 2026-01-31 14:19:50 +01:00
700b9d72bb auto-git:
[add] src/markdown/markdown-render.css
 [add] src/markdown/markdown.js
2026-01-31 14:15:28 +01:00
b6f6ca8e1b Rename and enhance editor title row styling 2026-01-31 13:37:53 +01:00
3114b936f1 Refactor editor layout in App.tsx 2026-01-31 13:37:51 +01:00
16 changed files with 13910 additions and 59 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -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",
]

View File

@@ -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"] }

View File

@@ -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",

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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")

View File

@@ -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"
]
}
]
}

View File

@@ -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;
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,6 +926,7 @@ export default function App() {
>
<section className="editor">
<div className="editor__header">
<div className="editor__title-row">
<input
className="title-input"
value={title}
@@ -869,11 +935,6 @@ export default function App() {
placeholder="Text title"
disabled={isViewingHistory}
/>
<div className="editor__status-row">
<div className="status-line">
<span className={`status status--${statusKey}`}></span>
{statusLabel}
</div>
<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,6 +974,13 @@ export default function App() {
))}
</div>
) : null}
{markdownPreview ? (
<div
className="markdown-preview md-root"
dangerouslySetInnerHTML={{ __html: markdownToHTML(body) }}
onClick={handleMarkdownPreviewClick}
/>
) : (
<textarea
ref={textareaRef}
className="editor__textarea"
@@ -918,13 +990,30 @@ export default function App() {
placeholder="Write your text here…"
readOnly={isViewingHistory}
/>
)}
</div>
<div className="editor__footer">
{hasText ? (
<>
<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}

View File

@@ -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;
}
}

View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const escapeAttr = (value = '') =>
escapeHtml(value)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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;
}