diff options
| -rwxr-xr-x | doc/todo.txt | 3 | ||||
| -rw-r--r-- | zenwrite/Cargo.toml | 2 | ||||
| -rw-r--r-- | zenwrite/nginx.conf | 40 | ||||
| -rw-r--r-- | zenwrite/src/main.rs | 415 |
4 files changed, 432 insertions, 28 deletions
diff --git a/doc/todo.txt b/doc/todo.txt index bb08a4a..c19cd89 100755 --- a/doc/todo.txt +++ b/doc/todo.txt @@ -1,3 +1,4 @@ - add vim keys functionality - more font adjustment/customization options -- add option to export in web version
\ No newline at end of file +- add ability to save colorschemes +- fix delete diff --git a/zenwrite/Cargo.toml b/zenwrite/Cargo.toml index d41e728..386b71b 100644 --- a/zenwrite/Cargo.toml +++ b/zenwrite/Cargo.toml @@ -20,4 +20,4 @@ rfd = "0.15" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Document", "Element", "HtmlCanvasElement", "Window", "Blob", "Url", "BlobPropertyBag"] }
\ No newline at end of file +web-sys = { version = "0.3", features = ["Document", "Element", "HtmlCanvasElement", "Window", "Blob", "Url", "BlobPropertyBag", "HtmlInputElement", "FileReader", "File"] }
\ No newline at end of file diff --git a/zenwrite/nginx.conf b/zenwrite/nginx.conf new file mode 100644 index 0000000..b0223ae --- /dev/null +++ b/zenwrite/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name zenwrite.example.com; + root /var/www/zenwrite/dist; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/wasm; + gzip_min_length 256; + gzip_static on; + + # WASM files are large; aggressive cache for fingerprinted assets + location ~* \.(wasm)$ { + add_header Cross-Origin-Opener-Policy "same-origin"; + add_header Cross-Origin-Embedder-Policy "require-corp"; + types { application/wasm wasm; } + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location ~* \.(js)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location ~* \.(css|png|jpg|jpeg|gif|ico|svg|ttf|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # index.html must never be cached so new builds are picked up + location = /index.html { + expires -1; + add_header Cache-Control "no-cache"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/zenwrite/src/main.rs b/zenwrite/src/main.rs index 9b2cccc..e256694 100644 --- a/zenwrite/src/main.rs +++ b/zenwrite/src/main.rs @@ -1,5 +1,6 @@ use chrono::Local; use eframe::egui; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use zenframe::{ load_json, paint_custom_cursor, save_json, CursorStyle, FadeTracker, SchemeDef, ThemesFile, @@ -18,6 +19,102 @@ fn default_themes() -> ThemesFile { serde_json::from_str(DEFAULT_THEMES).expect("embedded defaults.json is valid") } +#[cfg(target_arch = "wasm32")] +fn download_text(text: &str, filename: &str) { + use wasm_bindgen::JsCast; + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + let arr = js_sys::Array::new(); + arr.push(&wasm_bindgen::JsValue::from_str(text)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("text/plain"); + if let Ok(blob) = web_sys::Blob::new_with_str_sequence_and_options(&arr.into(), &opts) + { + if let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) { + if let Ok(a) = document.create_element("a") { + a.set_attribute("href", &url).ok(); + a.set_attribute("download", filename).ok(); + a.set_attribute("style", "display:none").ok(); + document.body().and_then(|body| body.append_child(&a).ok()); + if let Some(html_a) = a.dyn_ref::<web_sys::HtmlElement>() { + html_a.click(); + } + document.body().and_then(|body| body.remove_child(&a).ok()); + web_sys::Url::revoke_object_url(&url).ok(); + } + } + } + } + } +} + +#[cfg(target_arch = "wasm32")] +thread_local! { + static PENDING_FILE: std::cell::RefCell<Option<(String, String)>> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(target_arch = "wasm32")] +fn trigger_web_file_open() { + use wasm_bindgen::prelude::Closure; + use wasm_bindgen::JsCast; + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let document = match window.document() { + Some(d) => d, + None => return, + }; + + let input = match document.create_element("input") { + Ok(e) => e, + Err(_) => return, + }; + let input = match input.dyn_into::<web_sys::HtmlInputElement>() { + Ok(i) => i, + Err(_) => return, + }; + input.set_type("file"); + input.set_accept(".txt,.md,.rs,.py,.js,.ts,.html,.css,.json,.toml,.yaml,text/*"); + input.set_style("display:none"); + + let closure = Closure::once({ + let input = input.clone(); + move || { + let files = input.files(); + let file = match files.and_then(|f| f.item(0)) { + Some(f) => f, + None => return, + }; + let reader = match web_sys::FileReader::new() { + Ok(r) => r, + Err(_) => return, + }; + let filename = file.name(); + let onload = Closure::once(move || { + if let Ok(text) = reader.result() { + if let Some(text) = text.as_string() { + PENDING_FILE.with(|cell| *cell.borrow_mut() = Some((filename.clone(), text))); + } + } + }); + reader.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + reader.read_as_text(&file).ok(); + } + }); + + input.set_onchange(Some(closure.as_ref().unchecked_ref())); + closure.forget(); + if let Some(body) = document.body() { + body.append_child(&input).ok(); + input.click(); + body.remove_child(&input).ok(); + } +} + // ─── Config ─────────────────────────────────────────────── #[derive(Serialize, Deserialize, Clone, PartialEq)] @@ -37,11 +134,11 @@ impl Default for Config { fn default() -> Self { Self { animation_enabled: true, - fade_ms: 400, + fade_ms: 500, scheme: "Parchment".into(), font: "Crimson Pro".into(), font_size: 22.0, - cursor_style: CursorStyle::Default, + cursor_style: CursorStyle::Underline, custom_colors: None, data_dir: None, } @@ -98,6 +195,9 @@ struct ZenJournal { show_sidebar: bool, show_settings: bool, show_instructions: bool, + fullscreen: bool, + focus_entry: bool, + current_file_path: Option<String>, cursor_anim_pos: egui::Pos2, cursor_anim_init: bool, } @@ -116,11 +216,19 @@ impl ZenJournal { .push("CrimsonPro".to_owned()); cc.egui_ctx.set_fonts(fonts); - // Config is always loaded from the default location let config: Config = load_json(APP_NAME, CONFIG_KEY, None); + info!("config loaded (scheme: {}, font: {}, size: {})", config.scheme, config.font, config.font_size); + let data_dir = config.data_dir.clone(); + if let Some(ref dir) = data_dir { + info!("using custom data directory: {dir}"); + } + let themes = load_themes(data_dir.as_deref()); - let store = load_json(APP_NAME, STORE_KEY, data_dir.as_deref()); + info!("loaded {} color schemes, {} fonts", themes.color_schemes.len(), themes.fonts.len()); + + let store: JournalStore = load_json(APP_NAME, STORE_KEY, data_dir.as_deref()); + info!("journal loaded with {} existing entries", store.entries.len()); Self { store, @@ -132,6 +240,9 @@ impl ZenJournal { show_sidebar: true, show_settings: false, show_instructions: false, + fullscreen: false, + focus_entry: false, + current_file_path: None, cursor_anim_pos: egui::Pos2::ZERO, cursor_anim_init: false, } @@ -169,6 +280,9 @@ impl ZenJournal { } fn select_entry(&mut self, idx: usize) { + if self.current_file_path.is_some() { + self.close_file(); + } if let Some(prev) = self.selected { if let Some(entry) = self.store.entries.get_mut(prev) { entry.body.clone_from(&self.current_text); @@ -176,9 +290,90 @@ impl ZenJournal { } self.current_text.clone_from(&self.store.entries[idx].body); self.fade.reset(&self.current_text); + debug!("selected entry {idx} ({} chars)", self.current_text.len()); self.selected = Some(idx); } + fn new_entry(&mut self) { + if self.current_file_path.is_some() { + self.close_file(); + } + let date = Local::now().format("%B %d, %Y %H:%M").to_string(); + info!("creating new entry at {date}"); + self.store.entries.insert( + 0, + JournalEntry { + date, + body: String::new(), + }, + ); + self.selected = Some(0); + self.current_text.clear(); + self.fade.clear(); + self.focus_entry = true; + save_json(APP_NAME, STORE_KEY, &self.store, self.data_dir()); + } + + fn open_file(&mut self, path: String) { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::read_to_string(&path) { + Ok(content) => { + if self.current_file_path.is_some() { + self.close_file(); + } + self.current_file_path = Some(path.clone()); + self.current_text = content; + self.selected = None; + self.fade.clear(); + self.focus_entry = true; + info!("opened file {path} ({} bytes)", self.current_text.len()); + } + Err(e) => { + warn!("failed to open {path}: {e}"); + } + } + } + #[cfg(target_arch = "wasm32")] + { + // On web, content arrives via PENDING_FILE thread_local + if self.current_file_path.is_some() { + self.close_file(); + } + self.current_file_path = Some(path); + self.selected = None; + self.fade.clear(); + self.focus_entry = true; + info!("opening file from web picker"); + } + } + + fn select_next_entry(&mut self) { + if self.store.entries.is_empty() { + return; + } + let next = match self.selected { + Some(idx) if idx + 1 < self.store.entries.len() => idx + 1, + None => 0, + _ => return, + }; + debug!("next entry: {next}"); + self.select_entry(next); + } + + fn select_prev_entry(&mut self) { + if self.store.entries.is_empty() { + return; + } + let prev = match self.selected { + Some(idx) if idx > 0 => idx - 1, + None => 0, + _ => return, + }; + debug!("previous entry: {prev}"); + self.select_entry(prev); + } + fn export_current_entry(&self) { let Some(idx) = self.selected else { return }; let Some(entry) = self.store.entries.get(idx) else { return }; @@ -190,6 +385,8 @@ impl ZenJournal { .collect(); filename.push_str(".txt"); + info!("exporting entry {idx} as {filename} ({} bytes)", text.len()); + #[cfg(not(target_arch = "wasm32"))] { if let Some(path) = rfd::FileDialog::new() @@ -234,12 +431,102 @@ impl ZenJournal { } } } + + fn save_current_file(&mut self) { + let Some(ref path) = self.current_file_path.clone() else { return }; + #[cfg(not(target_arch = "wasm32"))] + { + if let Err(e) = std::fs::write(&path, &self.current_text) { + warn!("failed to save {path}: {e}"); + } else { + info!("saved {path} ({} bytes)", self.current_text.len()); + } + } + #[cfg(target_arch = "wasm32")] + { + // Download as a new file (can't overwrite original path in browser) + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("document.txt") + .to_owned(); + download_text(&self.current_text, &filename); + info!("downloaded {filename} ({} bytes)", self.current_text.len()); + } + } + + fn close_file(&mut self) { + if self.current_file_path.is_some() { + self.save_current_file(); + debug!("closed file"); + self.current_file_path = None; + if self.selected.is_none() { + self.current_text.clear(); + } else if let Some(idx) = self.selected { + self.current_text.clone_from(&self.store.entries[idx].body); + self.fade.reset(&self.current_text); + } + } + } } // ─── UI ─────────────────────────────────────────────────── impl eframe::App for ZenJournal { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // ─── Keyboard shortcuts ─── + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F11)) { + self.fullscreen = !self.fullscreen; + debug!("fullscreen toggled: {}", self.fullscreen); + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N)) { + self.new_entry(); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::O)) { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(path) = rfd::FileDialog::new() + .set_title("Open File") + .add_filter("Text", &["txt", "md"]) + .add_filter("All", &["*"]) + .pick_file() + { + self.open_file(path.display().to_string()); + } + } + #[cfg(target_arch = "wasm32")] + trigger_web_file_open(); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::S)) { + if self.current_file_path.is_some() { + self.save_current_file(); + } + } + ctx.input_mut(|i| { + if i.consume_key( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::Tab, + ) { + self.select_prev_entry(); + } else if i.consume_key(egui::Modifiers::CTRL, egui::Key::Tab) { + self.select_next_entry(); + } + }); + + #[cfg(target_arch = "wasm32")] + PENDING_FILE.with(|cell| { + if let Some((filename, content)) = cell.borrow_mut().take() { + self.current_text = content; + self.open_file(filename); + } + }); + + if self.focus_entry { + self.focus_entry = false; + ctx.memory_mut(|mem| mem.request_focus(egui::Id::new("entry_editor"))); + } + egui::CentralPanel::default().show(ctx, |ui| { let scheme = self.resolve_scheme().clone(); let colors = scheme.colors(); @@ -300,6 +587,14 @@ impl eframe::App for ZenJournal { body(wui, "\u{2699} open settings"); body(wui, "? this help window"); + head(wui, "shortcuts"); + body(wui, "F11 toggle fullscreen"); + body(wui, "Ctrl+N new entry"); + body(wui, "Ctrl+O open file"); + body(wui, "Ctrl+S save file"); + body(wui, "Ctrl+Tab next entry"); + body(wui, "Ctrl+Shift+Tab previous entry"); + head(wui, "customization"); body(wui, "Choose a color scheme, font, and size in settings."); body(wui, "Select \u{201c}Custom\u{201d} for a full color wheel."); @@ -345,7 +640,7 @@ impl eframe::App for ZenJournal { egui::RichText::new("speed").size(13.0).color(colors.muted), ); ui.add( - egui::Slider::new(&mut self.config.fade_ms, 100..=1000) + egui::Slider::new(&mut self.config.fade_ms, 20..=3000) .suffix(" ms"), ); }); @@ -479,7 +774,7 @@ impl eframe::App for ZenJournal { } self.show_settings = settings_open; if self.config != old_config { - // Config always saved to default location + debug!("settings changed (scheme: {}, font: {}, size: {})", self.config.scheme, self.config.font, self.config.font_size); save_json(APP_NAME, CONFIG_KEY, &self.config, None); } @@ -518,18 +813,7 @@ impl eframe::App for ZenJournal { ) .clicked() { - let date = Local::now().format("%B %d, %Y %H:%M").to_string(); - self.store.entries.insert( - 0, - JournalEntry { - date, - body: String::new(), - }, - ); - self.selected = Some(0); - self.current_text.clear(); - self.fade.clear(); - save_json(APP_NAME, STORE_KEY, &self.store, self.data_dir()); + self.new_entry(); } ui.add_space(4.0); @@ -598,12 +882,15 @@ impl eframe::App for ZenJournal { ); if row_interact.clicked() && to_delete != Some(i) { to_select = Some(i); + info!("sidebar clicked: entry {i}"); } ui.add_space(4.0); } if let Some(i) = to_delete { + let preview = self.store.entries[i].body.chars().take(40).collect::<String>().replace('\n', " "); + info!("deleting entry {i}: {preview}"); self.store.entries.remove(i); match self.selected { Some(s) if s == i => { @@ -653,9 +940,20 @@ impl eframe::App for ZenJournal { .clicked() { self.show_sidebar = !self.show_sidebar; + debug!("sidebar toggled: {}", self.show_sidebar); } - if let Some(idx) = self.selected { + if let Some(ref path) = self.current_file_path { + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path); + ui.label( + egui::RichText::new(name) + .size(13.0) + .color(colors.accent), + ); + } else if let Some(idx) = self.selected { if let Some(entry) = self.store.entries.get(idx) { ui.label( egui::RichText::new(&entry.date) @@ -666,7 +964,21 @@ impl eframe::App for ZenJournal { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if self.selected.is_some() { + if self.current_file_path.is_some() { + if ui + .add( + egui::Button::new( + egui::RichText::new("\u{21E9}") + .size(16.0) + .color(colors.muted), + ) + .frame(false), + ) + .clicked() + { + self.save_current_file(); + } + } else if self.selected.is_some() { if ui .add( egui::Button::new( @@ -681,6 +993,42 @@ impl eframe::App for ZenJournal { self.export_current_entry(); } } + if self.current_file_path.is_some() { + if ui + .add( + egui::Button::new( + egui::RichText::new("\u{00d7}") + .size(18.0) + .color(colors.muted), + ) + .frame(false), + ) + .clicked() + { + self.close_file(); + } + } + if ui + .add( + egui::Button::new( + egui::RichText::new("\u{25C1}").size(18.0).color(colors.muted), + ) + .frame(false), + ) + .clicked() + { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = rfd::FileDialog::new() + .set_title("Open File") + .add_filter("Text", &["txt", "md"]) + .add_filter("All", &["*"]) + .pick_file() + { + self.open_file(path.display().to_string()); + } + #[cfg(target_arch = "wasm32")] + trigger_web_file_open(); + } if ui .add( egui::Button::new( @@ -691,6 +1039,7 @@ impl eframe::App for ZenJournal { .clicked() { self.show_instructions = !self.show_instructions; + debug!("instructions toggled: {}", self.show_instructions); } if ui .add( @@ -704,13 +1053,14 @@ impl eframe::App for ZenJournal { .clicked() { self.show_settings = !self.show_settings; + debug!("settings toggled: {}", self.show_settings); } }); }); ui.add_space(20.0); - if self.selected.is_some() { + if self.selected.is_some() || self.current_file_path.is_some() { let font = self.body_font(); let scroll_area = egui::ScrollArea::vertical() .auto_shrink([false, false]); @@ -718,15 +1068,20 @@ impl eframe::App for ZenJournal { let output = scroll_area .show(ui, |ui| { egui::TextEdit::multiline(&mut self.current_text) + .id_source("entry_editor") .font(font.clone()) .text_color(colors.ink) .desired_width(ui.available_width()) .desired_rows(((ui.available_height() / 30.0) as usize).max(10)) .frame(false) .hint_text( - egui::RichText::new("Begin writing...") - .color(colors.muted) - .font(font), + egui::RichText::new(if self.current_file_path.is_some() { + "Start typing..." + } else { + "Begin writing..." + }) + .color(colors.muted) + .font(font), ) .show(ui) }) @@ -791,12 +1146,18 @@ impl eframe::App for ZenJournal { } if output.response.changed() { + if self.current_file_path.is_some() { + self.save_current_file(); + } if let Some(idx) = self.selected { if let Some(entry) = self.store.entries.get_mut(idx) { entry.body.clone_from(&self.current_text); } } - save_json(APP_NAME, STORE_KEY, &self.store, self.data_dir()); + if self.selected.is_some() { + debug!("auto-saved entry"); + save_json(APP_NAME, STORE_KEY, &self.store, self.data_dir()); + } } } else { ui.vertical_centered(|ui| { @@ -811,7 +1172,7 @@ impl eframe::App for ZenJournal { ); ui.add_space(8.0); ui.label( - egui::RichText::new("create a new entry to begin") + egui::RichText::new("create a new entry or press Ctrl+O to open a file") .font(egui::FontId::new( 18.0, egui::FontFamily::Name("CrimsonPro".into()), @@ -830,6 +1191,7 @@ impl eframe::App for ZenJournal { #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result<()> { env_logger::init(); + info!("starting zenwrite (native)"); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -850,6 +1212,7 @@ fn main() { use eframe::wasm_bindgen::JsCast as _; eframe::WebLogger::init(log::LevelFilter::Debug).ok(); + info!("starting zenwrite (web)"); let web_options = eframe::WebOptions::default(); |
