summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xdoc/todo.txt3
-rw-r--r--zenwrite/Cargo.toml2
-rw-r--r--zenwrite/nginx.conf40
-rw-r--r--zenwrite/src/main.rs415
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();