Touch up the mobile view, add syntax highlighting for markdown
This commit is contained in:
parent
7976babb79
commit
16450f4c1c
11 changed files with 863 additions and 57 deletions
284
Cargo.lock
generated
284
Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
|
|
@ -145,6 +151,21 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
|
|
@ -245,6 +266,27 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -261,6 +303,23 @@ version = "0.1.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -329,6 +388,12 @@ dependencies = [
|
|||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
|
|
@ -445,6 +510,16 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -474,6 +549,12 @@ version = "0.2.189"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -539,6 +620,16 @@ dependencies = [
|
|||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
|
|
@ -550,6 +641,12 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -571,6 +668,28 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "onig"
|
||||
version = "6.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"onig_sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onig_sys"
|
||||
version = "69.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
|
|
@ -606,6 +725,31 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"indexmap",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
|
|
@ -657,6 +801,15 @@ version = "0.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
|
|
@ -685,7 +838,8 @@ dependencies = [
|
|||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"syntect",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
|
|
@ -701,6 +855,12 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
|
|
@ -713,6 +873,15 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -801,6 +970,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -857,13 +1032,43 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "syntect"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"flate2",
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"onig",
|
||||
"plist",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -877,6 +1082,47 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
|
|
@ -1043,6 +1289,16 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
|
@ -1094,6 +1350,15 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
|
@ -1162,6 +1427,21 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ anyhow = "1"
|
|||
thiserror = "1"
|
||||
percent-encoding = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
syntect = "5"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ GUI applications: a menu bar, split-pane editor, and file-based note storage.
|
|||
- **File-based storage** — notes are persisted as Markdown files in `notes/`
|
||||
- **28 built-in themes** — from Dark/Light to Solarized, Dracula, Nord, Monokai, and seasonal palettes
|
||||
- **Markdown extras** — tables, tasklists, strikethrough, and footnotes via `pulldown-cmark`
|
||||
- **Syntax highlighting** — code blocks are highlighted using the current theme's ANSI palette via `syntect`
|
||||
- **Mobile responsive** — collapses to a Notes/Write/Preview tab layout on phones, with 44px touch targets
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod notes;
|
|||
mod pages;
|
||||
mod routes;
|
||||
mod storage;
|
||||
mod syntax;
|
||||
mod themes;
|
||||
|
||||
use axum::Router;
|
||||
|
|
@ -30,6 +31,9 @@ async fn main() {
|
|||
// Ensure notes directory exists
|
||||
std::fs::create_dir_all("notes").expect("Failed to create notes directory");
|
||||
|
||||
// Eagerly initialize syntax highlighting state so the first request isn't slow.
|
||||
let _ = syntax::syntax_state();
|
||||
|
||||
let app = Router::new()
|
||||
.merge(routes::router())
|
||||
.nest_service("/static", ServeDir::new("static"));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
use pulldown_cmark::{html, Options, Parser};
|
||||
use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
pub fn render_markdown(input: &str) -> String {
|
||||
use crate::syntax::{highlight_code, syntax_state};
|
||||
use crate::themes::Theme;
|
||||
|
||||
pub fn render_markdown(input: &str, theme: &Theme) -> String {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
|
|
@ -8,19 +11,50 @@ pub fn render_markdown(input: &str) -> String {
|
|||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
|
||||
let parser = Parser::new_ext(input, options);
|
||||
let mut events: Vec<Event<'_>> = Vec::new();
|
||||
let mut parser = parser.peekable();
|
||||
|
||||
while let Some(event) = parser.next() {
|
||||
if let Event::Start(Tag::CodeBlock(kind)) = event {
|
||||
let lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
|
||||
CodeBlockKind::Indented => None,
|
||||
};
|
||||
|
||||
let mut code = String::new();
|
||||
loop {
|
||||
match parser.next() {
|
||||
Some(Event::Text(text)) => code.push_str(&text),
|
||||
Some(Event::End(TagEnd::CodeBlock)) => break,
|
||||
Some(other) => {
|
||||
// Defensive: if we see something unexpected, push it through.
|
||||
events.push(other);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let highlighted = highlight_code(&code, lang.as_deref(), theme, syntax_state());
|
||||
events.push(Event::Html(highlighted.into()));
|
||||
} else {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
html::push_html(&mut html_output, events.into_iter());
|
||||
html_output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::themes::get_theme;
|
||||
|
||||
#[test]
|
||||
fn renders_headings_and_paragraphs() {
|
||||
let md = "# Hello\n\nThis is a paragraph.";
|
||||
let html = render_markdown(md);
|
||||
let html = render_markdown(md, get_theme("Dark"));
|
||||
assert!(html.contains("<h1>Hello</h1>"));
|
||||
assert!(html.contains("<p>This is a paragraph.</p>"));
|
||||
}
|
||||
|
|
@ -28,7 +62,7 @@ mod tests {
|
|||
#[test]
|
||||
fn renders_tasklists() {
|
||||
let md = "- [x] Done\n- [ ] Todo";
|
||||
let html = render_markdown(md);
|
||||
let html = render_markdown(md, get_theme("Dark"));
|
||||
assert!(html.contains("<input"));
|
||||
assert!(html.contains("checked"));
|
||||
}
|
||||
|
|
@ -36,8 +70,27 @@ mod tests {
|
|||
#[test]
|
||||
fn renders_tables() {
|
||||
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
|
||||
let html = render_markdown(md);
|
||||
let html = render_markdown(md, get_theme("Dark"));
|
||||
assert!(html.contains("<table>"));
|
||||
assert!(html.contains("<th>A</th>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_rust_code_blocks() {
|
||||
let md = "```rust\nfn main() {\n println!(\"hello\");\n}\n```";
|
||||
let html = render_markdown(md, get_theme("Dark"));
|
||||
assert!(html.contains("<pre"));
|
||||
assert!(html.contains("fn"));
|
||||
assert!(html.contains("main"));
|
||||
// Syntect emits inline style spans for highlighted tokens.
|
||||
assert!(html.contains("style=\"color:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_for_unknown_language() {
|
||||
let md = "```notareallanguage\nsome code\n```";
|
||||
let html = render_markdown(md, get_theme("Dark"));
|
||||
assert!(html.contains("some code"));
|
||||
assert!(html.contains("<pre"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
src/pages.rs
101
src/pages.rs
|
|
@ -14,15 +14,23 @@ pub fn render_app(
|
|||
let theme = get_theme(theme_name);
|
||||
let css = theme.to_css_block();
|
||||
let rendered_preview = current_note
|
||||
.map(|n| render_markdown(&n.content))
|
||||
.map(|n| render_markdown(&n.content, theme))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Mobile lands on the note list when there is no note selected,
|
||||
// otherwise on the editor. Server-rendered so it works without JS.
|
||||
let default_tab = if current_note.is_some() {
|
||||
"write"
|
||||
} else {
|
||||
"notes"
|
||||
};
|
||||
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" data-theme=(theme_name) {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
|
||||
title { "Rasuti Notes" }
|
||||
style { (PreEscaped(css)) }
|
||||
style { (PreEscaped(include_str!("../static/style.css"))) }
|
||||
|
|
@ -34,10 +42,11 @@ pub fn render_app(
|
|||
@if let Some(msg) = message {
|
||||
div class="flash-message" { (msg) }
|
||||
}
|
||||
(render_tab_bar(default_tab))
|
||||
main class="app-body" {
|
||||
(render_sidebar(notes, current_note))
|
||||
(render_editor(current_note))
|
||||
(render_preview(&rendered_preview))
|
||||
(render_sidebar(notes, current_note, default_tab))
|
||||
(render_editor(current_note, default_tab))
|
||||
(render_preview(&rendered_preview, default_tab))
|
||||
}
|
||||
(render_status_bar(notes.len(), theme_name))
|
||||
script {
|
||||
|
|
@ -57,11 +66,44 @@ fn escape_js(s: &str) -> String {
|
|||
.replace('\r', "\\r")
|
||||
}
|
||||
|
||||
fn render_tab_bar(default_tab: &str) -> Markup {
|
||||
html! {
|
||||
nav class="tab-bar" {
|
||||
button type="button" class="tab-button" data-tab-target="notes"
|
||||
x-on:click="setTab('notes')"
|
||||
x-bind:class="{ 'active': tab === 'notes' }" {
|
||||
span class="tab-icon" { "\u{1F4C3}" }
|
||||
span { "Notes" }
|
||||
}
|
||||
button type="button" class="tab-button" data-tab-target="write"
|
||||
x-on:click="setTab('write')"
|
||||
x-bind:class="{ 'active': tab === 'write' }" {
|
||||
span class="tab-icon" { "\u{270E}" }
|
||||
span { "Write" }
|
||||
}
|
||||
button type="button" class="tab-button" data-tab-target="preview"
|
||||
x-on:click="setTab('preview')"
|
||||
x-bind:class="{ 'active': tab === 'preview' }" {
|
||||
span class="tab-icon" { "\u{1F4C4}" }
|
||||
span { "Preview" }
|
||||
}
|
||||
script {
|
||||
(PreEscaped(format!(
|
||||
"window.__DEFAULT_TAB__ = \"{}\";",
|
||||
default_tab
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_menu_bar() -> Markup {
|
||||
html! {
|
||||
nav class="menu-bar" {
|
||||
ul class="menu-list" {
|
||||
li class="menu-item" tabindex="0" {
|
||||
li class="menu-item" tabindex="0"
|
||||
x-on:click="toggleMenu('file')"
|
||||
x-bind:class="{ 'open': openMenu === 'file' }" {
|
||||
span { "File" }
|
||||
ul class="menu-dropdown" {
|
||||
li {
|
||||
|
|
@ -75,21 +117,27 @@ fn render_menu_bar() -> Markup {
|
|||
}
|
||||
}
|
||||
}
|
||||
li class="menu-item" tabindex="0" {
|
||||
li class="menu-item" tabindex="0"
|
||||
x-on:click="toggleMenu('edit')"
|
||||
x-bind:class="{ 'open': openMenu === 'edit' }" {
|
||||
span { "Edit" }
|
||||
ul class="menu-dropdown" {
|
||||
li { button x-on:click="focusEditor()" { "Focus Editor" } }
|
||||
li { button x-on:click="saveCurrentNote()" { "Save Note" } }
|
||||
}
|
||||
}
|
||||
li class="menu-item" tabindex="0" {
|
||||
li class="menu-item" tabindex="0"
|
||||
x-on:click="toggleMenu('view')"
|
||||
x-bind:class="{ 'open': openMenu === 'view' }" {
|
||||
span { "View" }
|
||||
ul class="menu-dropdown" {
|
||||
li { a href="/" { "Show All Notes" } }
|
||||
li { button x-on:click="togglePreview()" { "Toggle Preview" } }
|
||||
}
|
||||
}
|
||||
li class="menu-item" tabindex="0" {
|
||||
li class="menu-item" tabindex="0"
|
||||
x-on:click="toggleMenu('theme')"
|
||||
x-bind:class="{ 'open': openMenu === 'theme' }" {
|
||||
span { "Theme" }
|
||||
ul class="menu-dropdown" {
|
||||
@for theme in PREDEFINED_THEMES {
|
||||
|
|
@ -102,7 +150,9 @@ fn render_menu_bar() -> Markup {
|
|||
}
|
||||
}
|
||||
}
|
||||
li class="menu-item" tabindex="0" {
|
||||
li class="menu-item" tabindex="0"
|
||||
x-on:click="toggleMenu('help')"
|
||||
x-bind:class="{ 'open': openMenu === 'help' }" {
|
||||
span { "Help" }
|
||||
ul class="menu-dropdown" {
|
||||
li { a href="/about" { "About Rasuti Notes" } }
|
||||
|
|
@ -154,9 +204,15 @@ fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name:
|
|||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
|
||||
fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str) -> Markup {
|
||||
let active = if default_tab == "notes" {
|
||||
" mobile-active"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
html! {
|
||||
aside class="sidebar" {
|
||||
aside class={"sidebar" (active)} data-tab-pane="notes"
|
||||
x-bind:class="{ 'mobile-active': tab === 'notes' }" {
|
||||
div class="sidebar-header" { "Notes" }
|
||||
ul class="note-list" {
|
||||
@if notes.is_empty() {
|
||||
|
|
@ -179,9 +235,16 @@ fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_editor(current_note: Option<&Note>) -> Markup {
|
||||
fn render_editor(current_note: Option<&Note>, default_tab: &str) -> Markup {
|
||||
let active = if default_tab == "write" {
|
||||
" mobile-active"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
html! {
|
||||
section class="editor-pane" x-data="{ saving: false }" {
|
||||
section class={"editor-pane" (active)} data-tab-pane="write"
|
||||
x-data="{ saving: false }"
|
||||
x-bind:class="{ 'mobile-active': tab === 'write' }" {
|
||||
@if let Some(note) = current_note {
|
||||
form
|
||||
id="note-form"
|
||||
|
|
@ -220,9 +283,15 @@ fn render_editor(current_note: Option<&Note>) -> Markup {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_preview(html_content: &str) -> Markup {
|
||||
fn render_preview(html_content: &str, default_tab: &str) -> Markup {
|
||||
let active = if default_tab == "preview" {
|
||||
" mobile-active"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
html! {
|
||||
section class="preview-pane" x-bind:class="{ 'hidden': !previewVisible }" {
|
||||
section class={"preview-pane" (active)} data-tab-pane="preview"
|
||||
x-bind:class="{ 'hidden': !previewVisible, 'mobile-active': tab === 'preview' }" {
|
||||
div id="preview" class="markdown-body" x-html="previewHtml" {
|
||||
(PreEscaped(html_content))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,18 +6,12 @@ use axum::{
|
|||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::markdown::render_markdown;
|
||||
use crate::notes::{self, Note};
|
||||
use crate::pages;
|
||||
use crate::themes::get_theme;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
// Could hold shared config in the future
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
|
|
@ -30,7 +24,6 @@ pub fn router() -> Router {
|
|||
.route("/theme", post(theme_route))
|
||||
.route("/about", get(about_route))
|
||||
.route("/export", get(export_route))
|
||||
.with_state(Arc::new(AppState {}))
|
||||
}
|
||||
|
||||
fn get_theme_from_cookie(headers: &HeaderMap) -> String {
|
||||
|
|
@ -143,8 +136,13 @@ struct PreviewForm {
|
|||
content: String,
|
||||
}
|
||||
|
||||
async fn preview_route(Form(form): Form<PreviewForm>) -> Result<impl IntoResponse, AppError> {
|
||||
let html = render_markdown(&form.content);
|
||||
async fn preview_route(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PreviewForm>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let theme_name = get_theme_from_cookie(&headers);
|
||||
let theme = get_theme(&theme_name);
|
||||
let html = render_markdown(&form.content, theme);
|
||||
Ok(Html(pages::render_preview_fragment(&html).into_string()))
|
||||
}
|
||||
|
||||
|
|
|
|||
121
src/syntax.rs
Normal file
121
src/syntax.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use crate::themes::{Theme, PREDEFINED_THEMES};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use syntect::highlighting::{Theme as SyntectTheme, ThemeSet};
|
||||
use syntect::html::highlighted_html_for_string;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
pub struct SyntaxState {
|
||||
pub syntax_set: SyntaxSet,
|
||||
pub themes: HashMap<String, SyntectTheme>,
|
||||
}
|
||||
|
||||
impl SyntaxState {
|
||||
pub fn new() -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let mut themes = HashMap::new();
|
||||
|
||||
for theme in PREDEFINED_THEMES {
|
||||
let tmtheme = generate_tmtheme(theme);
|
||||
let syntect_theme = ThemeSet::load_from_reader(&mut std::io::Cursor::new(tmtheme))
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to load generated theme for {}: {}", theme.name, e);
|
||||
ThemeSet::load_defaults().themes["InspiredGitHub"].clone()
|
||||
});
|
||||
themes.insert(theme.name.to_string(), syntect_theme);
|
||||
}
|
||||
|
||||
Self { syntax_set, themes }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight_code(
|
||||
code: &str,
|
||||
lang: Option<&str>,
|
||||
theme: &Theme,
|
||||
state: &SyntaxState,
|
||||
) -> String {
|
||||
let syntect_theme = state
|
||||
.themes
|
||||
.get(theme.name)
|
||||
.expect("Syntect theme should exist for every Rasuti theme");
|
||||
|
||||
let syntax = match lang {
|
||||
Some(lang) => state.syntax_set.find_syntax_by_token(lang),
|
||||
None => None,
|
||||
}
|
||||
.unwrap_or_else(|| state.syntax_set.find_syntax_plain_text());
|
||||
|
||||
highlighted_html_for_string(code, &state.syntax_set, syntax, syntect_theme)
|
||||
.unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)))
|
||||
}
|
||||
|
||||
static SYNTAX_STATE: OnceLock<SyntaxState> = OnceLock::new();
|
||||
|
||||
pub fn syntax_state() -> &'static SyntaxState {
|
||||
SYNTAX_STATE.get_or_init(SyntaxState::new)
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
|
||||
let scopes = [
|
||||
("comment", theme.ansi_bright[0]),
|
||||
("string", theme.ansi_bright[2]),
|
||||
("constant.numeric", theme.ansi_bright[3]),
|
||||
("constant", theme.ansi_bright[3]),
|
||||
("keyword", theme.ansi_bright[5]),
|
||||
("storage", theme.ansi_bright[5]),
|
||||
("entity.name.function", theme.ansi_bright[4]),
|
||||
("support.function", theme.ansi_bright[4]),
|
||||
("entity.name.type", theme.ansi_bright[6]),
|
||||
("variable", theme.ansi_bright[1]),
|
||||
("invalid", theme.ansi_bright[1]),
|
||||
];
|
||||
|
||||
let mut scope_entries = String::new();
|
||||
for (scope, color) in scopes {
|
||||
scope_entries.push_str(&format!(
|
||||
" <dict>\n\
|
||||
<key>name</key>\n\
|
||||
<string>{scope}</string>\n\
|
||||
<key>scope</key>\n\
|
||||
<string>{scope}</string>\n\
|
||||
<key>settings</key>\n\
|
||||
<dict>\n\
|
||||
<key>foreground</key>\n\
|
||||
<string>{color}</string>\n\
|
||||
</dict>\n\
|
||||
</dict>\n"
|
||||
));
|
||||
}
|
||||
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\">\n\
|
||||
<dict>\n\
|
||||
<key>name</key>\n\
|
||||
<string>Rasuti {}</string>\n\
|
||||
<key>settings</key>\n\
|
||||
<array>\n\
|
||||
<dict>\n\
|
||||
<key>settings</key>\n\
|
||||
<dict>\n\
|
||||
<key>background</key>\n\
|
||||
<string>{}</string>\n\
|
||||
<key>foreground</key>\n\
|
||||
<string>{}</string>\n\
|
||||
</dict>\n\
|
||||
</dict>\n\
|
||||
{}\
|
||||
</array>\n\
|
||||
</dict>\n\
|
||||
</plist>\n",
|
||||
theme.name, theme.bg, theme.fg, scope_entries
|
||||
)
|
||||
}
|
||||
|
|
@ -227,7 +227,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -238,7 +238,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -249,7 +249,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -260,7 +260,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -271,7 +271,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -395,11 +395,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn dark_themes_use_readable_button_colors() {
|
||||
// Themes with very dark bright-blue colors should fall back to the
|
||||
// theme foreground color so primary buttons remain readable.
|
||||
// Bright accent colors should be luminous enough to use as button
|
||||
// backgrounds; very dark accents fall back to the theme foreground.
|
||||
let cyan = get_theme("Cyan on Black");
|
||||
let (bg, fg) = cyan.button_colors(4);
|
||||
assert_eq!(bg, cyan.fg);
|
||||
// After replacing #0000ff with #a9a9f9, the bright blue is readable
|
||||
// and no longer falls back to the foreground color.
|
||||
assert_eq!(bg, cyan.ansi_bright[4]);
|
||||
assert_eq!(fg, cyan.bg);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,47 @@ function app() {
|
|||
return {
|
||||
previewHtml: window.__INITIAL_PREVIEW__ || '',
|
||||
previewVisible: true,
|
||||
tab: window.__DEFAULT_TAB__ || 'notes',
|
||||
// Only one menu can be open at a time. Keeping a single name here (rather
|
||||
// than a set of booleans) makes mutual exclusion automatic: opening Theme
|
||||
// while Help is open reassigns this value, so Help's dropdown is hidden.
|
||||
openMenu: null,
|
||||
_previewTimeout: null,
|
||||
|
||||
init() {
|
||||
// Close menus on any click outside the menu bar. Alpine's `.outside`
|
||||
// modifier cannot be used here because Maud treats `.` in attribute
|
||||
// names as CSS class shorthand, so we bind it manually.
|
||||
this._onDocClick = (event) => {
|
||||
const menuBar = document.querySelector('.menu-bar');
|
||||
if (this.openMenu && menuBar && !menuBar.contains(event.target)) {
|
||||
this.closeMenus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', this._onDocClick);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this._onDocClick) {
|
||||
document.removeEventListener('click', this._onDocClick);
|
||||
}
|
||||
},
|
||||
|
||||
toggleMenu(name) {
|
||||
this.openMenu = this.openMenu === name ? null : name;
|
||||
},
|
||||
|
||||
closeMenus() {
|
||||
this.openMenu = null;
|
||||
},
|
||||
|
||||
setTab(name) {
|
||||
this.tab = name;
|
||||
// Selecting the Preview tab should always reveal the preview, even if
|
||||
// the toolbar toggle had hidden it on desktop.
|
||||
if (name === 'preview') this.previewVisible = true;
|
||||
},
|
||||
|
||||
updatePreview(content) {
|
||||
if (this._previewTimeout) {
|
||||
clearTimeout(this._previewTimeout);
|
||||
|
|
@ -49,6 +88,10 @@ function app() {
|
|||
},
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.key === 'Escape' && this.openMenu) {
|
||||
this.closeMenus();
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
265
static/style.css
265
static/style.css
|
|
@ -14,6 +14,13 @@ html, body {
|
|||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ansi-4);
|
||||
text-decoration: none;
|
||||
|
|
@ -38,6 +45,7 @@ button, input, select, textarea {
|
|||
background-color: var(--app-bg);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +66,17 @@ button, input, select, textarea {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Menus are opened by click (see `openMenu` in app.js), not CSS hover, so
|
||||
that only one can be open at a time. `.open` both highlights the trigger
|
||||
and reveals its dropdown. */
|
||||
.menu-item:hover,
|
||||
.menu-item:focus {
|
||||
.menu-item:focus,
|
||||
.menu-item.open {
|
||||
background-color: var(--ui-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu-item:hover .menu-dropdown,
|
||||
.menu-item:focus-within .menu-dropdown {
|
||||
.menu-item.open .menu-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +86,12 @@ button, input, select, textarea {
|
|||
top: calc(100% - 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
/* The Theme menu has 28 entries; cap the height so it cannot run off
|
||||
the bottom of the viewport (body has overflow: hidden). */
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
background-color: var(--app-bg);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
|
|
@ -131,6 +148,7 @@ button, input, select, textarea {
|
|||
background-color: var(--app-bg);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +171,7 @@ button, input, select, textarea {
|
|||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--ui-hover);
|
||||
background-color: var(--ansi-12);
|
||||
border-color: var(--app-fg);
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +182,7 @@ button, input, select, textarea {
|
|||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--btn-primary-bg);
|
||||
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +193,7 @@ button, input, select, textarea {
|
|||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--btn-danger-bg);
|
||||
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
|
|
@ -207,13 +227,15 @@ button, input, select, textarea {
|
|||
color: var(--app-bg);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* App Body */
|
||||
.app-body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 1fr;
|
||||
height: calc(100vh - 32px - 44px - 24px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -482,6 +504,7 @@ button, input, select, textarea {
|
|||
border-top: 1px solid var(--ui-border);
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +512,7 @@ button, input, select, textarea {
|
|||
.about-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.about-page main {
|
||||
|
|
@ -498,27 +521,237 @@ button, input, select, textarea {
|
|||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar — hidden on desktop */
|
||||
.tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.app-body {
|
||||
grid-template-columns: 220px 1fr 1fr;
|
||||
grid-template-columns: 240px 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* Mobile: <= 600px (covers iPhone 11 at 390px CSS width) */
|
||||
@media (max-width: 600px) {
|
||||
html, body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Menu bar wraps to a second row.
|
||||
NOTE: do not use `overflow: auto` here — it would create a new stacking
|
||||
context and trap the absolutely-positioned `.menu-dropdown` underneath
|
||||
the toolbar and content panes. */
|
||||
.menu-bar {
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Constrain the dropdown to the viewport width and a conservative height
|
||||
on phones. Safe to use `overflow` here: it only clips the dropdown's own
|
||||
contents, it does not trap the dropdown inside an ancestor's context. */
|
||||
.menu-dropdown {
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: 55dvh;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toolbar wraps instead of stretching off-screen */
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-form {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-form select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Apple HIG minimum 44px touch targets */
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-form input,
|
||||
.theme-form select {
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
font-size: 16px; /* prevents iOS zoom-on-focus */
|
||||
}
|
||||
|
||||
/* Tab bar replaces the 3-column layout on mobile */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--app-bg);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
padding: 4px 8px;
|
||||
gap: 4px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--app-fg);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background-color: var(--ui-hover);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Single-pane view: only the active tab's pane is displayed */
|
||||
.app-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.app-body > * {
|
||||
display: none;
|
||||
height: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
max-height: 200px;
|
||||
border-bottom: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
.app-body > .mobile-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Sidebar fills the pane on mobile */
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.note-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.note-item a {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
/* Editor fills the pane on mobile */
|
||||
.note-title-input {
|
||||
font-size: 18px;
|
||||
padding: 14px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-content-input {
|
||||
font-size: 16px; /* prevents iOS zoom-on-focus */
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Preview fills the pane on mobile */
|
||||
.markdown-body {
|
||||
padding: 20px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Status bar stays compact */
|
||||
.status-bar {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.status-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Prevent horizontal page scroll */
|
||||
.flash-message {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.about-page main {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue