Compare commits
310 Commits
c12d7d03a5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 627d98c199 | |||
| 5db45375f3 | |||
| cb814d94fa | |||
| 4393a40ad2 | |||
| 7e854bd5d1 | |||
| 1c664523a6 | |||
| ebaa428f05 | |||
| 97e9b26251 | |||
| 73beaea077 | |||
| 9a42468cfd | |||
| 2b0023d3e1 | |||
| 790848342c | |||
| f4e79276f0 | |||
| ef1201f753 | |||
| d128ac92dc | |||
| 4432251158 | |||
| 79cd72f24f | |||
| 727ef480c5 | |||
| 2c20ab748e | |||
| cd2c4d3782 | |||
| 507a6f9f9a | |||
| ee36277c21 | |||
| f7a5c21d52 | |||
| e5e24eb7f3 | |||
| 07cfb06ee6 | |||
| 6d414d1592 | |||
| f255f9a5a1 | |||
| 7df435d18d | |||
| ffb83f9c71 | |||
| a4e863751b | |||
| e2b739ec6b | |||
| 18f8ccc62a | |||
| 625be7503d | |||
| 4da563df70 | |||
| f0765c1f42 | |||
| 43e1fb1c79 | |||
| b211342e8a | |||
| f5f1b76bfb | |||
| d5bfefefd1 | |||
| a7d5b58dbf | |||
| 6fa837ab61 | |||
| a3947147d6 | |||
| f9bea7ecaa | |||
| b40c8eae06 | |||
| 14e1f40ecf | |||
| b2b0afdaf6 | |||
| f1c96d66e6 | |||
| e92af02673 | |||
| db20dd5107 | |||
| 56ab395eb2 | |||
| 7671d6e39a | |||
| c8c7814db4 | |||
| 7bbdab340c | |||
| 41393e59bb | |||
| fbd573d9e5 | |||
| 30727b4bee | |||
| c7b7c40137 | |||
| e0e86b9199 | |||
| 7d8d7f6110 | |||
| b7850c270a | |||
| a2eb529fcf | |||
| ff5c3073dc | |||
| c4ad259362 | |||
| 9b634ccc05 | |||
| cb66d08027 | |||
| 98b01098a1 | |||
| 63efadd3fb | |||
| af01951d9a | |||
| 2b4b86816c | |||
| 9232054331 | |||
| c9edc1e266 | |||
| a16a4ef0f9 | |||
| 621c7a3681 | |||
| 4266708e8b | |||
| 57cff46ccd | |||
| 1215ab8fae | |||
| d9ee24e7cb | |||
| 3a30d6799e | |||
| a47eab7a03 | |||
| a433510f05 | |||
| 4413a57c0d | |||
| 7f2fe4a823 | |||
| 497738eb73 | |||
| 3f026499c0 | |||
| 80f928a1f7 | |||
| 742eecb873 | |||
| 9bf4cf1866 | |||
| 864b3f4c88 | |||
| 7bee133a9c | |||
| fefa100175 | |||
| cf92325bed | |||
| 44e64df922 | |||
| 7e2964e9f9 | |||
| 2f84b8bce1 | |||
| 485e8b4ed8 | |||
| eecba280a3 | |||
| 4cc25c1f87 | |||
| f4baeeca3d | |||
| 2265ae70b5 | |||
| 18945e578e | |||
| 27ca21094d | |||
| 9671f63828 | |||
| 2f459a0df4 | |||
| fd734a7c64 | |||
| 7518f032a2 | |||
| 3456700ccd | |||
| f0b7a49611 | |||
| 9f962afcb5 | |||
| 0413d87a55 | |||
| aeafaf1d96 | |||
| 7921384ddf | |||
| b843b72670 | |||
| 14ed5afe7b | |||
| 34d8434fb3 | |||
| 9ecbe6ec65 | |||
| 3ccb7bbe68 | |||
| 28809839f2 | |||
| 97a3871a8d | |||
| 8c1cf0e6a0 | |||
| 6c528d6fb0 | |||
| 81a7a4fac8 | |||
| 5b5371d3a8 | |||
| a1f129a9cb | |||
| 44b00e4a13 | |||
| 9dbce4ebee | |||
| 18ae1c3d3b | |||
| 15a1b22c91 | |||
| afe8a39085 | |||
| ddba57d28b | |||
| 10c6043e4e | |||
| 574e7899a5 | |||
| 2d256af21f | |||
| 3ed9de1c54 | |||
| dd288313b9 | |||
| 699b55b8fd | |||
| 997c8767e6 | |||
| b5a6b556c9 | |||
| 51e8fea77b | |||
| 7e170dfe85 | |||
| 78758331bb | |||
| 2ea212fd53 | |||
| b907855513 | |||
| 0024acdf40 | |||
| 9936fd2c0f | |||
| 1c9af949b4 | |||
| 3a2569daad | |||
| 28edef621c | |||
| e1e223ed55 | |||
| ec5cf906d2 | |||
| 016c2a3d4e | |||
| 6701d7fed9 | |||
| 5b93cf2916 | |||
| a7d69afc8b | |||
| 03621a0f91 | |||
| db0c456543 | |||
| de0ad07fc9 | |||
| 891cf63261 | |||
| 3ed4c36ee9 | |||
| c8545e9670 | |||
| dc1a515a2a | |||
| 5f16ebe51e | |||
| 3466e1901b | |||
| 733378d10f | |||
| 0863282fdf | |||
| 02449dd491 | |||
| bc5dd85b3a | |||
| 5bbe9f65b7 | |||
| b2c139fe18 | |||
| 31e0f19487 | |||
| 9a26725c30 | |||
| 5c82bb9f89 | |||
| 379ea3b653 | |||
| 3ef4b7e514 | |||
| 2d39935045 | |||
| 25561dd7f8 | |||
| f75fdf8e41 | |||
| 10812ee712 | |||
| fe9910a220 | |||
| 1fcef8864f | |||
| ecf860c3a7 | |||
| 23af6343ca | |||
| 85ed81de10 | |||
| bc350b5fe9 | |||
| 1d2f45af5b | |||
| c66fee4541 | |||
| 30d03e20c1 | |||
| 9b88e9306c | |||
| 0b0d3daddc | |||
| 3d1857d290 | |||
| 7a8324b22f | |||
| 0b140b19b7 | |||
| 3a48a0a38d | |||
| d8db15f958 | |||
| 13b10dccef | |||
| d7b2d19759 | |||
| dee929998c | |||
| 7c6e14a71b | |||
| 975190efd0 | |||
| e4aa205ab2 | |||
| 12088163dc | |||
| d78911d17e | |||
| 4d61e4d5cc | |||
| 3970a30f59 | |||
| 4baeb10e6d | |||
| bdb67d4093 | |||
| 45d7cd940d | |||
| 9e07db5a2d | |||
| 710eb65f46 | |||
| 0e8085a98b | |||
| 196cdd1764 | |||
| efdb3cc710 | |||
| 4ae3a0282e | |||
| c31bb9ec65 | |||
| 80654dc515 | |||
| 72715d0b9c | |||
| d7bc9a4358 | |||
| 077f9df03e | |||
| 5a92648a7d | |||
| 5fa861b315 | |||
| 7a7ab7f067 | |||
| 4da9032467 | |||
| 14fe9f9f2b | |||
| 663d7750ba | |||
| 1d9c1246dd | |||
| 4ae8f6dfc2 | |||
| bc68d4b661 | |||
| 58184c0f51 | |||
| 33177fa035 | |||
| 074f27b835 | |||
| 8ad329c9cd | |||
| e3dc293f81 | |||
| 00613f60d5 | |||
| cd512e8456 | |||
| 14203c33a1 | |||
| f4120dfab3 | |||
| 69f9b41708 | |||
| a19c167769 | |||
| 892e060bc1 | |||
| 7d758364e6 | |||
| 15e61635a9 | |||
| 131ef453a6 | |||
| 296e0242a1 | |||
| 3042a8e92d | |||
| a0a9e2360b | |||
| 988e2ed55a | |||
| 2297cab6a7 | |||
| 7fae29bb71 | |||
| e0e5a20d4d | |||
| 96ef5f40f6 | |||
| 197e71304a | |||
| 1f044ea362 | |||
| 361621a059 | |||
| e58c21bb38 | |||
| d4497751fe | |||
| 1921b63ffc | |||
| 354c974bd8 | |||
| 16a9dab119 | |||
| 67dcb3f9e4 | |||
| 740af2021c | |||
| 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
@@ -11,4 +11,5 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/gen/
|
||||
*.lock
|
||||
|
||||
27
README.md
@@ -1,19 +1,19 @@
|
||||
# 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 and local AI writing tools. 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. With a local Ollama model (recommended: `ministral:3b`), it can convert plain text to Markdown, proofread, summarize, translate, rewrite, change style, or run a custom instruction against a whole text or a selected passage. No accounts, no telemetry, no remote fonts, and no outside network calls.
|
||||
|
||||
## Features
|
||||
|
||||
- Non-destructive editing: manual saves are immutable; drafts never overwrite manual versions.
|
||||
- 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.
|
||||
- Autosave draft (debounced) that can be safely discarded.
|
||||
- History panel listing versions by timestamp (drafts included when present).
|
||||
- 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.
|
||||
- Non-destructive editing: immutable manual saves + safe autosaved drafts.
|
||||
- Version history panel with quick restore.
|
||||
- Markdown preview mode with split view, printable output, clickable links, and automatic raw URL detection.
|
||||
- Local AI tools via Ollama for Markdown conversion, proofreading, summarizing, translation, rewriting, style changes, and custom prompts.
|
||||
- AI edits can run on the whole text or just the selected part, and results are written back as drafts.
|
||||
- In-document search with Cmd/Ctrl+F, result highlighting, and next / previous navigation.
|
||||
- Live document stats in the footer: characters, words, sentences, and estimated tokens.
|
||||
- Sort your texts in nested folders with expandable tree; move items around via right-click.
|
||||
- Open/import text files from the file picker or system “Open With”; export to `.txt`, export the database, or print / `.pdf`.
|
||||
- Settings for theme, text size, line numbers, split view, Ollama connection, and editable AI prompt templates.
|
||||
|
||||
## Use cases
|
||||
|
||||
@@ -22,6 +22,7 @@ TextDB is a **non-destructive**, completely offline, local-first text editor. It
|
||||
- **Research notes**: maintain evolving notes with an audit trail of changes.
|
||||
- **Sensitive drafts**: keep local-only writing with immutable history and zero cloud sync.
|
||||
- **Prompt or snippet library**: store and iterate on reusable text safely.
|
||||
- **Offline writing assistant**: run local AI edits without sending text to external services.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -43,10 +44,13 @@ npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
For local AI tools, install Ollama: `https://ollama.com`
|
||||
|
||||
## Autosave + versioning behavior
|
||||
|
||||
- Typing triggers a debounced autosave (~600ms) that writes to the draft record.
|
||||
- Manual saves (Cmd/Ctrl+S or the button) append a new version and clear any draft.
|
||||
- AI edits preserve the current text state first when needed, then write the AI result back as a new draft.
|
||||
- Autosave only overwrites the single draft row; manual versions are never overwritten.
|
||||
- History shows all manual versions plus the current draft (if any).
|
||||
|
||||
@@ -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, split view, sidebar + folder collapse state, last selected text, and AI prompt templates) are stored in localStorage.
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.12",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^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"
|
||||
|
||||
639
src-tauri/Cargo.lock
generated
@@ -5,12 +5,14 @@ description = "TextDB"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-build = { version = "2.5", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2.10", 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/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/macOS-schema.json
Normal file
BIN
src-tauri/icons/folder.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/folder_b.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/new_folder.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/new_folder_b.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/new_text.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/new_text_b.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/open_file.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/open_file_b.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/sb_collapse.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/sb_collapse_b.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/sb_expand.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/sb_expand_b.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/settings.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/settings_b.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
@@ -13,13 +13,12 @@ fn take_pending_opens(state: tauri::State<PendingOpens>) -> Vec<String> {
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
app.manage(PendingOpens(Mutex::new(Vec::new())));
|
||||
Ok(())
|
||||
})
|
||||
.manage(PendingOpens(Mutex::new(Vec::new())))
|
||||
.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")
|
||||
@@ -34,9 +33,10 @@ fn main() {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let state = app.state::<PendingOpens>();
|
||||
let mut pending = state.0.lock().expect("pending opens lock");
|
||||
pending.extend(paths.iter().cloned());
|
||||
if let Some(state) = app.try_state::<PendingOpens>() {
|
||||
let mut pending = state.0.lock().expect("pending opens lock");
|
||||
pending.extend(paths.iter().cloned());
|
||||
}
|
||||
let _ = app.emit("file-opened", paths);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,6 +60,20 @@
|
||||
"contentTypes": [
|
||||
"public.plain-text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ext": [
|
||||
"md",
|
||||
"markdown"
|
||||
],
|
||||
"name": "Markdown Document",
|
||||
"description": "Markdown document",
|
||||
"mimeType": "text/markdown",
|
||||
"role": "Editor",
|
||||
"rank": "Default",
|
||||
"contentTypes": [
|
||||
"net.daringfireball.markdown"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2642
src/App.tsx
650
src/index.css
@@ -21,6 +21,7 @@
|
||||
--bg-active: #202020;
|
||||
--border-strong: #555;
|
||||
--danger-hover: #4f4f4f;
|
||||
--tree-line: #5f5f5f;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
@@ -41,6 +42,7 @@ body[data-theme="light"] {
|
||||
--bg-active: #e6e6e6;
|
||||
--border-strong: #b5b5b5;
|
||||
--danger-hover: #d6d6d6;
|
||||
--tree-line: #9a9a9a;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +79,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
padding: 24px 0 16px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100vh;
|
||||
@@ -107,15 +109,17 @@ body {
|
||||
|
||||
.sidebar__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.sidebar__footer .button {
|
||||
width: 100%;
|
||||
.sidebar__footer .icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
@@ -131,6 +135,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--ink);
|
||||
caret-color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -178,7 +183,6 @@ body {
|
||||
}
|
||||
|
||||
.button--save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
@@ -229,6 +233,17 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prompt-item__input {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--bg-input);
|
||||
caret-color: var(--ink);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.prompt-item__delete {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -243,7 +258,7 @@ body {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
@@ -260,16 +275,157 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prompt-item__meta {
|
||||
font-size: 0.8rem;
|
||||
.folder-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--tree-line);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body:not([data-theme="light"]) .prompt-item,
|
||||
body:not([data-theme="light"]) .folder-item {
|
||||
background: var(--bg-contrast);
|
||||
}
|
||||
|
||||
.folder-item__toggle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: -4px;
|
||||
font-size: 1.6rem;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.folder-item__lead {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.folder-item__icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex: 0 0 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.folder-item__title {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.folder-item__delete {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.folder-item__delete:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.folder-item__input {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--bg-input);
|
||||
caret-color: var(--ink);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-children > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-children > *::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: -10px;
|
||||
bottom: -10px;
|
||||
width: 1px;
|
||||
background: var(--tree-line);
|
||||
}
|
||||
|
||||
.folder-children > :first-child::after {
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.folder-children > :last-child::after {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.folder-children .folder-item::before,
|
||||
.folder-children .prompt-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 1px;
|
||||
background: var(--tree-line);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 28px 32px;
|
||||
padding: 16px 32px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace__topbar {
|
||||
@@ -293,7 +449,7 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -319,13 +475,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;
|
||||
@@ -333,6 +492,7 @@ body {
|
||||
padding: 6px 2px 8px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
@@ -348,6 +508,11 @@ body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-line--secondary {
|
||||
margin-top: 4px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -431,67 +596,190 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor__textarea-wrap--preview {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__textarea-wrap--preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body:not([data-theme="light"]) .editor__textarea-wrap--preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.editor__textarea-wrap--split {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__textarea-wrap--split {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body:not([data-theme="light"]) .editor__textarea-wrap--split {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.editor__textarea-wrap--split .editor__codemirror {
|
||||
flex: 1;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor__textarea-wrap--split .markdown-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__textarea-wrap--split .markdown-preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body:not([data-theme="light"]) .editor__textarea-wrap--split .markdown-preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.editor__textarea-wrap:focus-within {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
padding: 14px 10px;
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-family: "SF Mono", "Menlo", "Consolas", monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
background: var(--bg-contrast);
|
||||
border-right: 1px solid var(--border);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-numbers__line {
|
||||
height: 1.5em;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.line-measure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "SF Mono", "Menlo", "Consolas", monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.line-measure__line {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor__textarea {
|
||||
.editor__codemirror {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: none;
|
||||
padding: 14px;
|
||||
font-family: "SF Mono", "Menlo", "Consolas", monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor__codemirror--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-editor {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: var(--base-font-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-editor.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor,
|
||||
.editor__codemirror .cm-dropCursor {
|
||||
border-left-color: #f5f5f5 !important;
|
||||
border-left-width: 1.6px !important;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-fatCursor {
|
||||
background: rgba(245, 245, 245, 0.45);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-gutters {
|
||||
background: var(--bg-contrast);
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-gutterElement {
|
||||
padding: 0 10px 0 0;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-activeLineGutter {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-panels {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--ink);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-panels-top {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search input,
|
||||
.editor__codemirror .cm-search button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search input[type="text"] {
|
||||
min-width: 220px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.editor__textarea:focus {
|
||||
outline: none;
|
||||
.editor__codemirror .cm-search button {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-input);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search button:hover {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search [name="close"] {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-search input[type="checkbox"] {
|
||||
accent-color: var(--ink);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-searchMatch {
|
||||
background: rgba(214, 214, 96, 0.28);
|
||||
outline: 1px solid rgba(214, 214, 96, 0.38);
|
||||
}
|
||||
|
||||
.editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
|
||||
background: rgba(233, 216, 112, 0.42);
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .markdown-preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body:not([data-theme="light"]) .markdown-preview {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.markdown-preview--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor__footer {
|
||||
@@ -501,6 +789,23 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor__footer-status {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -658,6 +963,10 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal__card--wide {
|
||||
width: min(560px, 92vw);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -666,6 +975,37 @@ body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal__textarea {
|
||||
width: 100%;
|
||||
min-height: 170px;
|
||||
resize: vertical;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--ink);
|
||||
caret-color: var(--ink);
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.modal__hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal__selection-preview {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--ink);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -756,6 +1096,8 @@ body {
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@@ -768,7 +1110,8 @@ body {
|
||||
.settings-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(420px, 92vw);
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-elevated);
|
||||
@@ -777,6 +1120,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-panel__header {
|
||||
@@ -789,17 +1133,55 @@ body {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-panel__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.settings-panel__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-panel__section--flush {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-panel__section-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-panel__label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-panel__input,
|
||||
.settings-panel__textarea {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--ink);
|
||||
caret-color: var(--ink);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-panel__textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.settings-panel__select {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -826,10 +1208,87 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-panel__hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-panel__status {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.settings-panel__status--success {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.settings-panel__status--error {
|
||||
color: #d36b6b;
|
||||
}
|
||||
|
||||
.prompt-accordion {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-accordion__toggle {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-accordion__indicator {
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prompt-accordion__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.prompt-accordion__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__textarea-wrap:focus-within {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__codemirror .cm-editor.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor,
|
||||
body[data-theme="light"] .editor__codemirror .cm-dropCursor {
|
||||
border-left-color: #1f1f1f !important;
|
||||
border-left-width: 1.6px !important;
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__codemirror .cm-fatCursor {
|
||||
background: rgba(31, 31, 31, 0.28);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__codemirror .cm-searchMatch {
|
||||
background: rgba(219, 199, 81, 0.3);
|
||||
outline-color: rgba(170, 141, 33, 0.28);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
|
||||
background: rgba(219, 199, 81, 0.44);
|
||||
}
|
||||
|
||||
.settings-panel__section--row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -839,3 +1298,60 @@ 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 {
|
||||
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 .editor__codemirror {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.print-markdown .editor__textarea-wrap--split {
|
||||
display: block;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
199
src/lib/db.ts
@@ -6,6 +6,15 @@ export type Text = {
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_saved_version_id: string | null;
|
||||
folder_id: string | null;
|
||||
};
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type TextVersion = {
|
||||
@@ -26,12 +35,21 @@ export type TextDraft = {
|
||||
|
||||
const MIGRATIONS = [
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
`CREATE TABLE IF NOT EXISTS folders(
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(parent_id) REFERENCES folders(id)
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS prompts(
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_saved_version_id TEXT
|
||||
last_saved_version_id TEXT,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS prompt_versions(
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -50,7 +68,9 @@ const MIGRATIONS = [
|
||||
FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
|
||||
);`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt_time ON prompt_versions(prompt_id, created_at DESC);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);"
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_folders_updated ON folders(updated_at DESC);"
|
||||
];
|
||||
|
||||
let dbPromise: Promise<Database> | null = null;
|
||||
@@ -59,6 +79,91 @@ async function migrate(db: Database) {
|
||||
for (const statement of MIGRATIONS) {
|
||||
await db.execute(statement);
|
||||
}
|
||||
await ensureColumn(db, "prompts", "folder_id", "TEXT REFERENCES folders(id) ON DELETE SET NULL");
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_folder ON prompts(folder_id);");
|
||||
await dropSortOrderColumns(db);
|
||||
}
|
||||
|
||||
async function ensureColumn(
|
||||
db: Database,
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string
|
||||
) {
|
||||
const columns = await db.select<{ name: string }[]>(
|
||||
`PRAGMA table_info(${table})`
|
||||
);
|
||||
if (columns.some((col) => col.name === column)) return;
|
||||
await db.execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
async function hasColumn(db: Database, table: string, column: string) {
|
||||
const columns = await db.select<{ name: string }[]>(
|
||||
`PRAGMA table_info(${table})`
|
||||
);
|
||||
return columns.some((col) => col.name === column);
|
||||
}
|
||||
|
||||
async function dropSortOrderColumns(db: Database) {
|
||||
const promptsHasSort = await hasColumn(db, "prompts", "sort_order");
|
||||
const foldersHasSort = await hasColumn(db, "folders", "sort_order");
|
||||
if (!promptsHasSort && !foldersHasSort) return;
|
||||
|
||||
await db.execute("PRAGMA foreign_keys = OFF");
|
||||
await db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
if (foldersHasSort) {
|
||||
await db.execute("DROP TABLE IF EXISTS folders_new");
|
||||
await db.execute(`
|
||||
CREATE TABLE folders_new(
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(parent_id) REFERENCES folders(id)
|
||||
);
|
||||
`);
|
||||
await db.execute(`
|
||||
INSERT INTO folders_new(id, name, parent_id, created_at, updated_at)
|
||||
SELECT id, name, parent_id, created_at, updated_at FROM folders
|
||||
`);
|
||||
await db.execute("DROP TABLE folders");
|
||||
await db.execute("ALTER TABLE folders_new RENAME TO folders");
|
||||
}
|
||||
|
||||
if (promptsHasSort) {
|
||||
await db.execute("DROP TABLE IF EXISTS prompts_new");
|
||||
await db.execute(`
|
||||
CREATE TABLE prompts_new(
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_saved_version_id TEXT,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
await db.execute(`
|
||||
INSERT INTO prompts_new(id, title, created_at, updated_at, last_saved_version_id, folder_id)
|
||||
SELECT id, title, created_at, updated_at, last_saved_version_id, folder_id FROM prompts
|
||||
`);
|
||||
await db.execute("DROP TABLE prompts");
|
||||
await db.execute("ALTER TABLE prompts_new RENAME TO prompts");
|
||||
}
|
||||
|
||||
await db.execute("COMMIT");
|
||||
} catch (error) {
|
||||
await db.execute("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
await db.execute("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);");
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_folder ON prompts(folder_id);");
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);");
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_folders_updated ON folders(updated_at DESC);");
|
||||
}
|
||||
|
||||
export async function getDb(): Promise<Database> {
|
||||
@@ -79,7 +184,8 @@ export async function initDb() {
|
||||
export async function listTexts(): Promise<Text[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<Text[]>(
|
||||
"SELECT * FROM prompts ORDER BY updated_at DESC"
|
||||
`SELECT * FROM prompts
|
||||
ORDER BY updated_at DESC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -144,7 +250,8 @@ export async function getDraft(promptId: string): Promise<TextDraft | null> {
|
||||
|
||||
export async function createText(
|
||||
title: string,
|
||||
body: string
|
||||
body: string,
|
||||
folderId: string | null = null
|
||||
): Promise<{ textId: string; versionId: string; createdAt: number }> {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
@@ -152,8 +259,8 @@ export async function createText(
|
||||
const versionId = crypto.randomUUID();
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO prompts(id, title, created_at, updated_at, last_saved_version_id) VALUES($1, $2, $3, $4, $5)",
|
||||
[textId, title, now, now, versionId]
|
||||
"INSERT INTO prompts(id, title, created_at, updated_at, last_saved_version_id, folder_id) VALUES($1, $2, $3, $4, $5, $6)",
|
||||
[textId, title, now, now, versionId, folderId]
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
@@ -166,6 +273,80 @@ export async function createText(
|
||||
return { textId, versionId, createdAt: now };
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<Folder[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Folder[]>(
|
||||
`SELECT * FROM folders
|
||||
ORDER BY updated_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
name: string,
|
||||
parentId: string | null = null
|
||||
): Promise<{ folderId: string; createdAt: number }> {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
const folderId = crypto.randomUUID();
|
||||
await db.execute(
|
||||
"INSERT INTO folders(id, name, parent_id, created_at, updated_at) VALUES($1, $2, $3, $4, $5)",
|
||||
[folderId, name, parentId, now, now]
|
||||
);
|
||||
return { folderId, createdAt: now };
|
||||
}
|
||||
|
||||
export async function updateFolderName(folderId: string, name: string) {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
"UPDATE folders SET name = $1, updated_at = $2 WHERE id = $3",
|
||||
[name, now, folderId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function moveFolder(
|
||||
folderId: string,
|
||||
parentId: string | null
|
||||
) {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
"UPDATE folders SET parent_id = $1, updated_at = $2 WHERE id = $3",
|
||||
[parentId, now, folderId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteFolder(folderId: string) {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
const rows = await db.select<{ parent_id: string | null }[]>(
|
||||
"SELECT parent_id FROM folders WHERE id = $1 LIMIT 1",
|
||||
[folderId]
|
||||
);
|
||||
const parentId = rows[0]?.parent_id ?? null;
|
||||
|
||||
await db.execute(
|
||||
"UPDATE folders SET parent_id = $1, updated_at = $2 WHERE parent_id = $3",
|
||||
[parentId, now, folderId]
|
||||
);
|
||||
await db.execute(
|
||||
"UPDATE prompts SET folder_id = $1, updated_at = $2 WHERE folder_id = $3",
|
||||
[parentId, now, folderId]
|
||||
);
|
||||
await db.execute("DELETE FROM folders WHERE id = $1", [folderId]);
|
||||
}
|
||||
|
||||
export async function moveTextToFolder(
|
||||
textId: string,
|
||||
folderId: string | null
|
||||
) {
|
||||
const db = await getDb();
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
"UPDATE prompts SET folder_id = $1, updated_at = $2 WHERE id = $3",
|
||||
[folderId, now, textId]
|
||||
);
|
||||
}
|
||||
export async function saveManualVersion(
|
||||
promptId: string,
|
||||
title: string,
|
||||
@@ -253,3 +434,9 @@ export async function deleteTextVersion(promptId: string, versionId: string) {
|
||||
[nextId, promptId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportDatabaseSnapshot(destinationPath: string) {
|
||||
const db = await getDb();
|
||||
const escapedPath = destinationPath.replace(/'/g, "''");
|
||||
await db.execute(`VACUUM INTO '${escapedPath}'`);
|
||||
}
|
||||
|
||||
287
src/markdown/markdown-render.css
Normal file
@@ -0,0 +1,287 @@
|
||||
/* 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:not([data-theme="light"]) .md-root {
|
||||
--md-surface-alt: var(--bg);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .md-root {
|
||||
--md-link-color: #0a66c2;
|
||||
--md-link-hover-color: #004182;
|
||||
--md-surface-alt: var(--bg);
|
||||
--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);
|
||||
}
|
||||
345
src/markdown/markdown.js
Normal file
@@ -0,0 +1,345 @@
|
||||
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 '';
|
||||
};
|
||||
|
||||
const renderExternalLink = (label, hrefRaw) => {
|
||||
const url = safeLink(hrefRaw);
|
||||
const tooltip = escapeHtml(hrefRaw || '');
|
||||
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>`;
|
||||
};
|
||||
|
||||
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, (_, label, href) => {
|
||||
return renderExternalLink(label, href);
|
||||
});
|
||||
|
||||
const autoLinkPlainUrls = (source) => {
|
||||
const protectedChunkRe = /(<a\b[\s\S]*?<\/a>|<code>[\s\S]*?<\/code>|@@CODEBLOCK\d+@@)/g;
|
||||
const isProtectedChunk = /^(<a\b[\s\S]*<\/a>|<code>[\s\S]*<\/code>|@@CODEBLOCK\d+@@)$/;
|
||||
const trimTrailingPunctuation = (value) => {
|
||||
let linked = value;
|
||||
let trailing = '';
|
||||
while (linked.length > 0) {
|
||||
const last = linked[linked.length - 1];
|
||||
if (!/[)\].,!?;:]/.test(last)) break;
|
||||
if (last === ')') {
|
||||
const opens = (linked.match(/\(/g) || []).length;
|
||||
const closes = (linked.match(/\)/g) || []).length;
|
||||
if (closes <= opens) break;
|
||||
}
|
||||
linked = linked.slice(0, -1);
|
||||
trailing = last + trailing;
|
||||
}
|
||||
return { linked, trailing };
|
||||
};
|
||||
|
||||
return source
|
||||
.split(protectedChunkRe)
|
||||
.map((part) => {
|
||||
if (!part || isProtectedChunk.test(part)) return part;
|
||||
return part.replace(/(^|[\s(>])((?:https?:\/\/|www\.)[^\s<]+)/g, (_, lead, rawUrl) => {
|
||||
const { linked, trailing } = trimTrailingPunctuation(rawUrl);
|
||||
if (!linked) return `${lead}${rawUrl}`;
|
||||
const href = /^www\./i.test(linked) ? `https://${linked}` : linked;
|
||||
return `${lead}${renderExternalLink(linked, href)}${trailing}`;
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
html = autoLinkPlainUrls(html);
|
||||
|
||||
// 6) Convert line-breaks to HTML paragraphs and <br /> inside paragraphs
|
||||
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;
|
||||
}
|
||||