diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..243dd5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Generated by Cargo +# will have compiled files and executables +**/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +.vscode/ + +**/dist/ +node_modules/ + +# Local frontend tooling binaries +frontend/tools/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b85dbc7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "3" +members = [ + "frontend", + "shared", +] + +[workspace.lints.clippy] + all = "warn" + pedantic = "warn" + nursery = "warn" + cargo = "warn" \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c98abfc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ + +The MIT License (MIT) + +Copyright (c) 2026 Stoffl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d3be978..1672f48 100644 --- a/README.md +++ b/README.md @@ -1 +1,75 @@ -# BrickTutorialCreator +```md +# BrickCreator + +BrickCreator is a responsive web application built for the Catrobat Pocket Code community, especially educators and users who create learning materials for Pocket Code. + +The website works on both desktop and mobile devices and is included in the Catrobat repository ecosystem. + +## About + +BrickCreator allows users to create, edit, and import Pocket Code user bricks, then export them into multiple file formats for use in: + +- Presentations +- Tutorials +- Worksheets +- Teaching materials +- Documentation + +Supported import formats include: + +- JSON + +Supported export formats include: + +- PNG +- SVG +- JSON + +## Features + +- Create custom user bricks visually +- Import existing brick data +- Export bricks as PNG, SVG, or JSON +- Mobile-friendly and desktop-friendly design +- Easy to use for teachers and beginners + +## Purpose + +BrickCreator was developed to simplify the creation of educational content for Pocket Code. Instead of manually designing and capturing screenshots bricks for slides or tutorials, users can generate high-quality assets directly in the browser. + +## About Catrobat + +Catrobat is an open-source platform with contributors from all over the world. + +Learn more: https://catrobat.org/about + +## Compatibility + +BrickCreator works in modern web browsers on: + +- Desktop devices +- Tablets +- Smartphones + +## Running BrickCreator + +To run the project locally, make sure you have Rust and Trunk installed. + +1. Change into the frontend directory: + `cd frontend` +2. Start the development server: + `trunk serve` +3. Open BrickCreator in your browser at: + `http://127.0.0.1:8080` + +If you want to run it from the repository root instead, you can use: +`trunk serve --config frontend/Trunk.toml` + +## Contributing + +Contributions, ideas, and improvements are welcome through the Catrobat project. + +## License + +!!TODO Issue #13!! +``` diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml new file mode 100644 index 0000000..fec0fbe --- /dev/null +++ b/frontend/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "frontend" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +shared = { path = "../shared" } +serde_json = "1.0.149" +base64 = "0.22.1" + +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "EventTarget", "File", "FileList", "FileReader", "HtmlElement", "HtmlAnchorElement", "HtmlCanvasElement", "CanvasRenderingContext2d", "HtmlImageElement", "HtmlInputElement", "HtmlTextAreaElement", "DragEvent", "DataTransfer", "Blob", "BlobPropertyBag", "Url", "ResizeObserver", "ResizeObserverEntry", "TouchEvent", "TouchList", "Touch"] } +js-sys = "0.3" +console_error_panic_hook = "0.1" +yew = { version = "0.23", features = ["csr"] } +gloo = { version = "0.11", features = ["file"] } diff --git a/frontend/Trunk.toml b/frontend/Trunk.toml new file mode 100644 index 0000000..842cc2f --- /dev/null +++ b/frontend/Trunk.toml @@ -0,0 +1,3 @@ +[build] +target = "index.html" +dist = "dist" diff --git a/frontend/build.rs b/frontend/build.rs new file mode 100644 index 0000000..d0b7ff5 --- /dev/null +++ b/frontend/build.rs @@ -0,0 +1,63 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +fn collect_json_files(root: &Path) -> std::io::Result> { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let ty = entry.file_type()?; + if ty.is_dir() { + stack.push(path); + } else if ty.is_file() && path.extension().is_some_and(|e| e == "json") { + files.push(path); + } + } + } + + Ok(files) +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let bricks_root = manifest_dir.join("../utils/output"); + + println!("cargo:rerun-if-changed={}", bricks_root.display()); + + let mut files = collect_json_files(&bricks_root).expect("failed to read ../utils/output"); + files.sort(); + for file in &files { + println!("cargo:rerun-if-changed={}", file.display()); + } + + let mut out = String::new(); + out.push_str("// @generated by frontend/build.rs\n"); + out.push_str("pub const BRICKS: &[(&str, &str)] = &[\n"); + + for file in &files { + let rel = file + .strip_prefix(&bricks_root) + .expect("strip_prefix bricks_root") + .to_string_lossy() + .replace('\\', "/"); + let rel_lit = format!("{rel:?}"); + + out.push_str(" ("); + out.push_str(&rel_lit); + out.push_str( + ", include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../utils/output/\", ", + ); + out.push_str(&rel_lit); + out.push_str("))),\n"); + } + + out.push_str("];\n"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + fs::write(out_dir.join("brick_catalog.rs"), out).expect("write brick_catalog.rs"); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b5a7eb9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,11 @@ + + + + + + BrickCreator + + + + + diff --git a/frontend/src/app/application.rs b/frontend/src/app/application.rs new file mode 100644 index 0000000..e14a20d --- /dev/null +++ b/frontend/src/app/application.rs @@ -0,0 +1,414 @@ +#[cfg(target_arch = "wasm32")] +use super::ABOUT_MESSAGE; +#[cfg(target_arch = "wasm32")] +use crate::app as style; +#[cfg(target_arch = "wasm32")] +use crate::app::browser::{cached_download_callback, upload_json}; +#[cfg(target_arch = "wasm32")] +use crate::app::editors::brick_editor::BrickEditor; +#[cfg(target_arch = "wasm32")] +use crate::app::editors::tutorial_editor::TutorialEditor; +#[cfg(target_arch = "wasm32")] +use crate::app::export_selection; +#[cfg(target_arch = "wasm32")] +use crate::app::history::{AppSnapshot, next_history, pending_restore_count}; +#[cfg(target_arch = "wasm32")] +use crate::app::import_export::{ + ALL_BRICKS_TILE_WIDTH, export_selected_json, export_selected_png, export_selected_svg, + import_json_text, +}; +#[cfg(target_arch = "wasm32")] +use crate::app::theme::{apply_theme, load_saved_theme}; +#[cfg(target_arch = "wasm32")] +use crate::app::toolbar::{AppToolbar, IconExplanationModal}; +#[cfg(target_arch = "wasm32")] +use crate::components::sidebar::Sidebar; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::catalog; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::ninepatch; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::{TutorialAction, TutorialViewState}; +#[cfg(target_arch = "wasm32")] +use std::rc::Rc; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +fn close_menu(menu_open: &UseStateHandle, help_submenu_open: &UseStateHandle) { + menu_open.set(false); + help_submenu_open.set(false); +} + +#[cfg(target_arch = "wasm32")] +#[function_component(App)] +fn app() -> Html { + let brick = use_reducer(BrickState::default); + let tutorial = use_reducer(TutorialViewState::default); + + let export_mode = use_state(|| false); + let all_bricks_url = use_state(|| Option::::None); + let all_bricks_rendering = use_state(|| false); + let ninepatch_url = use_state(|| Option::::None); + let ninepatch_rendering = use_state(|| false); + let menu_open = use_state(|| false); + let help_submenu_open = use_state(|| false); + let explanation_modal_open = use_state(|| false); + let history = use_state(|| vec![AppSnapshot::default()]); + let history_index = use_state(|| 0usize); + let restoring_history = use_mut_ref(|| 0usize); + let light = use_state(|| { + let saved = load_saved_theme(); + apply_theme(saved); + saved + }); + + { + let history = history.clone(); + let history_index = history_index.clone(); + let restoring_history = restoring_history.clone(); + let snapshot = AppSnapshot { + brick: (*brick).clone(), + tutorial: (*tutorial).clone(), + }; + use_effect_with(snapshot, move |snapshot| { + let pending_restores = *restoring_history.borrow(); + if pending_restores > 0 { + *restoring_history.borrow_mut() = pending_restores - 1; + } else { + if let Some((next, next_index)) = next_history(&*history, *history_index, snapshot) + { + history.set(next); + history_index.set(next_index); + } + } + || () + }); + } + + let tutorial_len = (*tutorial).tutorial.content.len(); + let export_selection = use_state(|| vec![false; tutorial_len]); + { + let export_selection = export_selection.clone(); + use_effect_with(tutorial_len, move |len| { + export_selection.set(export_selection::resized(*len)); + || () + }); + } + + let on_toggle_export = { + let export_selection = export_selection.clone(); + Callback::from(move |index: usize| { + export_selection.set(export_selection::toggled(&*export_selection, index)); + }) + }; + + let on_export_select_all = { + let export_selection = export_selection.clone(); + Callback::from(move |_: MouseEvent| { + export_selection.set(export_selection::all_selected((*export_selection).len())); + }) + }; + + let on_export_clear = { + let export_selection = export_selection.clone(); + Callback::from(move |_: MouseEvent| { + export_selection.set(export_selection::cleared((*export_selection).len())); + }) + }; + + let brick_dispatcher = brick.dispatcher(); + let tutorial_dispatcher = tutorial.dispatcher(); + + let import_json = { + let tutorial_dispatcher = tutorial_dispatcher.clone(); + Callback::from(move |_: ()| { + let tutorial_dispatcher = tutorial_dispatcher.clone(); + upload_json(Callback::from(move |text: String| { + import_json_text(text, &tutorial_dispatcher); + })); + }) + }; + + let on_export_selected_json = { + let tutorial = tutorial.clone(); + let export_selection = export_selection.clone(); + let export_mode = export_mode.clone(); + Callback::from(move |_: MouseEvent| { + let selection = (*export_selection).clone(); + match export_selected_json(&*tutorial, &selection) { + Ok(()) => export_mode.set(false), + Err(error) => web_sys::console::error_1(&error.into()), + } + }) + }; + + let on_export_selected_png = { + let tutorial = tutorial.clone(); + let export_selection = export_selection.clone(); + let export_mode = export_mode.clone(); + Callback::from(move |_: MouseEvent| { + let selection = (*export_selection).clone(); + match export_selected_png(&*tutorial, &selection) { + Ok(()) => export_mode.set(false), + Err(error) => web_sys::console::error_1(&error.into()), + } + }) + }; + + let on_export_selected_svg = { + let tutorial = tutorial.clone(); + let export_selection = export_selection.clone(); + let export_mode = export_mode.clone(); + Callback::from(move |_: MouseEvent| { + let selection = (*export_selection).clone(); + match export_selected_svg(&*tutorial, &selection) { + Ok(()) => export_mode.set(false), + Err(error) => web_sys::console::error_1(&error.into()), + } + }) + }; + + let export_all_bricks_zip = cached_download_callback( + all_bricks_url.clone(), + all_bricks_rendering.clone(), + "all_bricks.zip", + "All bricks render error:", + Rc::new(|| { + catalog::render_all_bricks_zip_bytes(ALL_BRICKS_TILE_WIDTH).map_err(|e| e.to_string()) + }), + ); + + let export_ninepatch_zip = cached_download_callback( + ninepatch_url.clone(), + ninepatch_rendering.clone(), + "ninepatch_bricks.zip", + "Ninepatch render error:", + Rc::new(|| ninepatch::render_ninepatch_zip_bytes().map_err(|e| e.to_string())), + ); + + let on_enter_export_mode = { + let export_mode = export_mode.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + export_mode.set(true); + }) + }; + + let on_exit_export_mode = { + let export_mode = export_mode.clone(); + Callback::from(move |_: MouseEvent| export_mode.set(false)) + }; + + let on_import = { + let import_json = import_json.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + import_json.emit(()); + }) + }; + + let toggle_theme = { + let light = light.clone(); + Callback::from(move |_: MouseEvent| { + let new_val = !*light; + apply_theme(new_val); + light.set(new_val); + }) + }; + + let on_menu = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + Callback::from(move |_: MouseEvent| { + let next_open = !*menu_open; + menu_open.set(next_open); + if !next_open { + help_submenu_open.set(false); + } + }) + }; + + let on_menu_mouse_leave = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + Callback::from(move |_: MouseEvent| { + close_menu(&menu_open, &help_submenu_open); + }) + }; + + let on_undo = { + let history = history.clone(); + let history_index = history_index.clone(); + let restoring_history = restoring_history.clone(); + let brick = brick.clone(); + let tutorial = tutorial.clone(); + let brick_dispatcher = brick_dispatcher.clone(); + let tutorial_dispatcher = tutorial_dispatcher.clone(); + Callback::from(move |_: MouseEvent| { + let current_index = *history_index; + if current_index == 0 { + return; + } + let target_index = current_index - 1; + if let Some(snapshot) = (*history).get(target_index).cloned() { + let pending_restores = pending_restore_count(&*brick, &*tutorial, &snapshot); + *restoring_history.borrow_mut() = pending_restores; + brick_dispatcher + .dispatch(crate::interfaces::brick::StateAction::Set(snapshot.brick)); + tutorial_dispatcher.dispatch(TutorialAction::Restore(snapshot.tutorial)); + history_index.set(target_index); + } + }) + }; + + let on_redo = { + let history = history.clone(); + let history_index = history_index.clone(); + let restoring_history = restoring_history.clone(); + let brick = brick.clone(); + let tutorial = tutorial.clone(); + let brick_dispatcher = brick_dispatcher.clone(); + let tutorial_dispatcher = tutorial_dispatcher.clone(); + Callback::from(move |_: MouseEvent| { + let current_index = *history_index; + let target_index = current_index + 1; + if let Some(snapshot) = (*history).get(target_index).cloned() { + let pending_restores = pending_restore_count(&*brick, &*tutorial, &snapshot); + *restoring_history.borrow_mut() = pending_restores; + brick_dispatcher + .dispatch(crate::interfaces::brick::StateAction::Set(snapshot.brick)); + tutorial_dispatcher.dispatch(TutorialAction::Restore(snapshot.tutorial)); + history_index.set(target_index); + } + }) + }; + + let on_reset = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + let brick_dispatcher = brick_dispatcher.clone(); + Callback::from(move |_: MouseEvent| { + brick_dispatcher.dispatch(crate::interfaces::brick::StateAction::Reset); + close_menu(&menu_open, &help_submenu_open); + }) + }; + + let on_help = { + let help_submenu_open = help_submenu_open.clone(); + Callback::from(move |_: MouseEvent| { + help_submenu_open.set(!*help_submenu_open); + }) + }; + + let on_help_link_click = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + Callback::from(move |_: MouseEvent| { + close_menu(&menu_open, &help_submenu_open); + }) + }; + + let on_open_explanation = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + let explanation_modal_open = explanation_modal_open.clone(); + Callback::from(move |_: MouseEvent| { + close_menu(&menu_open, &help_submenu_open); + explanation_modal_open.set(true); + }) + }; + + let on_close_explanation = { + let explanation_modal_open = explanation_modal_open.clone(); + Callback::from(move |_: MouseEvent| explanation_modal_open.set(false)) + }; + + let on_about = { + let menu_open = menu_open.clone(); + let help_submenu_open = help_submenu_open.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(window) = web_sys::window() { + let _ = window.alert_with_message(ABOUT_MESSAGE); + } + close_menu(&menu_open, &help_submenu_open); + }) + }; + + let tutorial_bricks = (*tutorial).get_brick_state_list(); + let selected_count = (*export_selection) + .iter() + .filter(|selected| **selected) + .count(); + let can_undo = *history_index > 0; + let can_redo = *history_index + 1 < (*history).len(); + let can_reset = *brick != BrickState::default(); + html! { +
+ +
+
+ +
+ + + +
+ if *explanation_modal_open { + + } +
+ } +} + +#[cfg(target_arch = "wasm32")] +pub fn mount_app() { + yew::Renderer::::new().render(); +} diff --git a/frontend/src/app/browser.rs b/frontend/src/app/browser.rs new file mode 100644 index 0000000..c0ca2bb --- /dev/null +++ b/frontend/src/app/browser.rs @@ -0,0 +1,235 @@ +#[cfg(target_arch = "wasm32")] +use std::rc::Rc; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::closure::Closure; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; +#[allow(deprecated)] +#[cfg(target_arch = "wasm32")] +use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url}; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +fn blob_from_bytes(data: &[u8], mime: &str) -> Result { + let uint8 = js_sys::Uint8Array::from(data); + let parts = js_sys::Array::new(); + parts.push(&uint8.buffer()); + + let mut opts = BlobPropertyBag::new(); + #[allow(deprecated)] + opts.type_(mime); + + Blob::new_with_buffer_source_sequence_and_options(&parts, &opts) + .map_err(|e| format!("blob error: {e:?}")) +} + +#[cfg(target_arch = "wasm32")] +fn blob_from_text(data: &str, mime: &str) -> Result { + let parts = js_sys::Array::new(); + parts.push(&JsValue::from_str(data)); + + let mut opts = BlobPropertyBag::new(); + #[allow(deprecated)] + opts.type_(mime); + + Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(|e| format!("blob error: {e:?}")) +} + +#[cfg(target_arch = "wasm32")] +fn trigger_download_url(url: &str, filename: &str) -> Result<(), String> { + let document = web_sys::window() + .and_then(|w| w.document()) + .ok_or_else(|| "No document".to_string())?; + + let anchor: HtmlAnchorElement = document + .create_element("a") + .map_err(|e| format!("{e:?}"))? + .dyn_into() + .map_err(|e| format!("{e:?}"))?; + + anchor.set_href(url); + anchor.set_download(filename); + anchor.set_attribute("style", "display:none").ok(); + + if let Some(body) = document.body() { + body.append_child(&anchor).map_err(|e| format!("{e:?}"))?; + anchor.click(); + body.remove_child(&anchor).map_err(|e| format!("{e:?}"))?; + } else { + anchor.click(); + } + + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn schedule_revoke(url: String) -> Result<(), String> { + let window = web_sys::window().ok_or_else(|| "No window".to_string())?; + let revoke = Closure::once(move || { + let _ = Url::revoke_object_url(&url); + }); + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + revoke.as_ref().unchecked_ref(), + 60_000, + ) + .map_err(|e| format!("{e:?}"))?; + revoke.forget(); + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn download_blob(blob: &Blob, filename: &str, revoke: bool) -> Result { + let url = Url::create_object_url_with_blob(blob).map_err(|e| format!("URL error: {e:?}"))?; + trigger_download_url(&url, filename)?; + if revoke { + schedule_revoke(url.clone())?; + } + Ok(url) +} + +#[cfg(target_arch = "wasm32")] +pub fn download_bytes(data: &[u8], filename: &str, mime: &str) -> Result<(), String> { + let blob = blob_from_bytes(data, mime)?; + download_blob(&blob, filename, true).map(|_| ()) +} + +#[cfg(target_arch = "wasm32")] +pub fn download_text(data: &str, filename: &str, mime: &str) -> Result<(), String> { + let blob = blob_from_text(data, mime)?; + download_blob(&blob, filename, true).map(|_| ()) +} + +#[cfg(target_arch = "wasm32")] +pub fn download_png(data: &[u8], filename: &str) -> Result<(), String> { + download_bytes(data, filename, "image/png") +} + +#[cfg(target_arch = "wasm32")] +pub fn download_svg(svg: &str, filename: &str) -> Result<(), String> { + download_text(svg, filename, "image/svg+xml") +} + +#[cfg(target_arch = "wasm32")] +pub fn download_json(json: &str, filename: &str) -> Result<(), String> { + download_text(json, filename, "application/json") +} + +#[cfg(target_arch = "wasm32")] +pub fn cached_download_callback( + cached_url: UseStateHandle>, + rendering: UseStateHandle, + filename: &'static str, + label: &'static str, + render_bytes: Rc Result, String>>, +) -> Callback { + Callback::from(move |_: MouseEvent| { + if *rendering { + return; + } + if let Some(url) = (*cached_url).clone() { + let _ = trigger_download_url(&url, filename); + return; + } + + rendering.set(true); + let cached_url_done = cached_url.clone(); + let rendering_done = rendering.clone(); + let render_bytes = render_bytes.clone(); + + let callback = Closure::once(move || { + match render_bytes() + .and_then(|data| blob_from_bytes(&data, "application/zip")) + .and_then(|blob| download_blob(&blob, filename, false)) + { + Ok(url) => cached_url_done.set(Some(url)), + Err(error) => { + web_sys::console::error_1(&format!("{label} {error}").into()); + } + } + rendering_done.set(false); + }); + + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 0, + ); + callback.forget(); + } else { + rendering.set(false); + } + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn upload_json(on_load: yew::Callback) { + let document = match web_sys::window().and_then(|w| w.document()) { + Some(d) => d, + None => return, + }; + + let input: web_sys::HtmlInputElement = match document + .create_element("input") + .ok() + .and_then(|e| e.dyn_into().ok()) + { + Some(i) => i, + None => return, + }; + + input.set_type("file"); + input.set_attribute("accept", ".json,application/json").ok(); + input.set_attribute("style", "display:none").ok(); + + let body = match document.body() { + Some(b) => b, + None => return, + }; + body.append_child(&input).ok(); + + let input_clone = input.clone(); + let on_change = Closure::::new(move || { + let files = match input_clone.files() { + Some(f) => f, + None => return, + }; + let file = match files.get(0) { + Some(f) => f, + None => return, + }; + + let reader = match web_sys::FileReader::new().ok() { + Some(r) => r, + None => return, + }; + + let on_load = on_load.clone(); + let reader_clone = reader.clone(); + let onloadend = Closure::::new(move || { + if let Ok(result) = reader_clone.result() + && let Some(text) = result.as_string() + { + on_load.emit(text); + } + }); + + reader.set_onloadend(Some(onloadend.as_ref().unchecked_ref())); + onloadend.forget(); + + reader.read_as_text(&file).ok(); + + if let Some(parent) = input_clone.parent_node() { + parent.remove_child(&input_clone).ok(); + } + }); + + input.set_onchange(Some(on_change.as_ref().unchecked_ref())); + on_change.forget(); + + input.click(); +} diff --git a/frontend/src/app/editors/brick_editor.rs b/frontend/src/app/editors/brick_editor.rs new file mode 100644 index 0000000..38916f7 --- /dev/null +++ b/frontend/src/app/editors/brick_editor.rs @@ -0,0 +1,46 @@ +#[cfg(target_arch = "wasm32")] +use crate::app::views::brick_settings::BrickSettingsView; +#[cfg(target_arch = "wasm32")] +use crate::app::views::view::brick_preview_view::BrickPreviewView; +#[cfg(target_arch = "wasm32")] +use crate::components::editor_group::EditorGroup; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::{TutorialAction, TutorialViewState}; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct BrickEditorProps { + pub brick: BrickState, + pub dispatcher: UseReducerDispatcher, + pub tutorial_dispatcher: UseReducerDispatcher, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(BrickEditor)] +pub fn brick_editor(props: &BrickEditorProps) -> Html { + let on_add_to_tutorial = { + let dispatcher = props.tutorial_dispatcher.clone(); + let brick = props.brick.clone(); + Callback::from(move |_: MouseEvent| { + dispatcher.dispatch(TutorialAction::AddBrick(brick.clone())); + }) + }; + + html! { + + + + + } +} diff --git a/frontend/src/app/editors/mod.rs b/frontend/src/app/editors/mod.rs new file mode 100644 index 0000000..0131b9b --- /dev/null +++ b/frontend/src/app/editors/mod.rs @@ -0,0 +1,5 @@ +pub mod brick_editor; +pub mod tutorial_editor; + +#[cfg(target_arch = "wasm32")] +pub(crate) const TUTORIAL_EDITOR_CONTENT: &str = "flex min-h-0 min-w-0 flex-1 flex-col gap-app-gap"; diff --git a/frontend/src/app/editors/tutorial_editor.rs b/frontend/src/app/editors/tutorial_editor.rs new file mode 100644 index 0000000..2e19a46 --- /dev/null +++ b/frontend/src/app/editors/tutorial_editor.rs @@ -0,0 +1,160 @@ +#[cfg(target_arch = "wasm32")] +use crate::app::editors as style; +#[cfg(target_arch = "wasm32")] +use crate::app::views::brick_catalog_modal::BrickCatalogModal; +#[cfg(target_arch = "wasm32")] +use crate::app::views::view::tutorial_edit_view::TutorialEditView; +#[cfg(target_arch = "wasm32")] +use crate::app::views::view::tutorial_preview_view::TutorialPreviewView; +#[cfg(target_arch = "wasm32")] +use crate::app::views::view::tutorial_settings_view::TutorialSettingsView; +#[cfg(target_arch = "wasm32")] +use crate::components::editor_group::EditorGroup; +#[cfg(target_arch = "wasm32")] +use crate::components::export_bar::ExportBar; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::{BrickState, StateAction}; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::{TutorialAction, TutorialViewState}; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct TutorialEditorProps { + pub brick: BrickState, + pub brick_dispatcher: UseReducerDispatcher, + pub tutorial: TutorialViewState, + pub tutorial_dispatcher: UseReducerDispatcher, + pub export_selection: Vec, + pub on_toggle_export: Callback, + pub export_mode: bool, + pub selected_count: usize, + pub total_bricks: usize, + pub on_export_select_all: Callback, + pub on_export_clear: Callback, + pub on_export_json: Callback, + pub on_export_png: Callback, + pub on_export_svg: Callback, + pub on_exit_export: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(TutorialEditor)] +pub fn tutorial_editor(props: &TutorialEditorProps) -> Html { + let bricks = props.tutorial.get_brick_state_list(); + let preview_toggle = use_state(|| false); + let preview = *preview_toggle.clone(); + let catalog_open = use_state(|| false); + let preview_data = if preview { + props.tutorial.get_png(800).ok() + } else { + None + }; + + let on_remove = { + let dispatcher = props.tutorial_dispatcher.clone(); + Callback::from(move |_: MouseEvent| { + dispatcher.dispatch(TutorialAction::RemoveSelected); + }) + }; + + let on_apply = { + let dispatcher = props.tutorial_dispatcher.clone(); + let brick = props.brick.clone(); + Callback::from(move |_: MouseEvent| { + dispatcher.dispatch(TutorialAction::ApplyChanges(brick.clone())); + }) + }; + + let on_toggle_preview = { + Callback::from(move |_: MouseEvent| { + preview_toggle.clone().set(!*preview_toggle); + }) + }; + + let on_open_catalog = { + let catalog_open = catalog_open.clone(); + Callback::from(move |_: MouseEvent| { + catalog_open.set(true); + }) + }; + + let on_select = { + let dispatcher = props.tutorial_dispatcher.clone(); + let brick_dispatcher = props.brick_dispatcher.clone(); + let bricks = bricks.clone(); + Callback::from(move |index: usize| { + dispatcher.dispatch(TutorialAction::Select(index)); + if let Some(brick) = bricks.get(index) { + brick_dispatcher.dispatch(StateAction::Set(brick.clone())); + } + }) + }; + + let on_move = { + let dispatcher = props.tutorial_dispatcher.clone(); + Callback::from(move |(from, to): (usize, usize)| { + dispatcher.dispatch(TutorialAction::MoveEntry(from, to)); + }) + }; + + let has_selection = props.tutorial.selected_index.is_some(); + let selected_index = props.tutorial.selected_index; + + html! { + +
+ if *catalog_open { + + } + + if props.export_mode { + + } + if preview { + + } else { + + } +
+
+ } +} diff --git a/frontend/src/app/export_selection.rs b/frontend/src/app/export_selection.rs new file mode 100644 index 0000000..98c8a70 --- /dev/null +++ b/frontend/src/app/export_selection.rs @@ -0,0 +1,60 @@ +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::TutorialViewState; + +#[cfg(target_arch = "wasm32")] +pub fn selected_bricks(tutorial: &TutorialViewState, export_selection: &[bool]) -> Vec { + tutorial + .get_brick_state_list() + .into_iter() + .enumerate() + .filter_map(|(index, brick)| { + export_selection + .get(index) + .copied() + .unwrap_or(false) + .then_some(brick) + }) + .collect() +} + +#[cfg(target_arch = "wasm32")] +pub fn export_selected_bricks( + tutorial: &TutorialViewState, + export_selection: &[bool], + exporter: F, +) -> Result<(), String> +where + F: FnOnce(&[BrickState]) -> Result<(), String>, +{ + let selected = selected_bricks(tutorial, export_selection); + if selected.is_empty() { + return Err("Export error: no bricks selected".to_string()); + } + exporter(&selected) +} + +#[cfg(target_arch = "wasm32")] +pub fn resized(len: usize) -> Vec { + vec![false; len] +} + +#[cfg(target_arch = "wasm32")] +pub fn toggled(current: &[bool], index: usize) -> Vec { + let mut next = current.to_vec(); + if index < next.len() { + next[index] = !next[index]; + } + next +} + +#[cfg(target_arch = "wasm32")] +pub fn all_selected(len: usize) -> Vec { + vec![true; len] +} + +#[cfg(target_arch = "wasm32")] +pub fn cleared(len: usize) -> Vec { + vec![false; len] +} diff --git a/frontend/src/app/history.rs b/frontend/src/app/history.rs new file mode 100644 index 0000000..ba55080 --- /dev/null +++ b/frontend/src/app/history.rs @@ -0,0 +1,41 @@ +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::TutorialViewState; + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Default, PartialEq)] +pub struct AppSnapshot { + pub brick: BrickState, + pub tutorial: TutorialViewState, +} + +#[cfg(target_arch = "wasm32")] +pub fn next_history( + history: &[AppSnapshot], + current_index: usize, + snapshot: &AppSnapshot, +) -> Option<(Vec, usize)> { + if history + .get(current_index) + .map(|entry| entry == snapshot) + .unwrap_or(false) + { + return None; + } + + let mut next = history.to_vec(); + next.truncate(current_index + 1); + next.push(snapshot.clone()); + let next_index = current_index + 1; + Some((next, next_index)) +} + +#[cfg(target_arch = "wasm32")] +pub fn pending_restore_count( + current_brick: &BrickState, + current_tutorial: &TutorialViewState, + target: &AppSnapshot, +) -> usize { + usize::from(current_brick != &target.brick) + usize::from(current_tutorial != &target.tutorial) +} diff --git a/frontend/src/app/import_export.rs b/frontend/src/app/import_export.rs new file mode 100644 index 0000000..2efc22f --- /dev/null +++ b/frontend/src/app/import_export.rs @@ -0,0 +1,72 @@ +#[cfg(target_arch = "wasm32")] +use crate::app::browser; +#[cfg(target_arch = "wasm32")] +use crate::app::export_selection::export_selected_bricks; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::tutorial::{ + TutorialAction, TutorialViewState, tutorial_from_states, tutorial_png_bytes, +}; +#[cfg(target_arch = "wasm32")] +use shared::tutorial::Tutorial; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +pub const ALL_BRICKS_TILE_WIDTH: u32 = 192; +#[cfg(target_arch = "wasm32")] +pub const SELECTED_TUTORIAL_WIDTH: u32 = 1920; + +#[cfg(target_arch = "wasm32")] +pub fn import_json_text( + text: String, + tutorial_dispatcher: &UseReducerDispatcher, +) { + if Tutorial::from_json(&text).is_ok() { + tutorial_dispatcher.dispatch(TutorialAction::LoadJson(text)); + return; + } + if let Ok(brick) = BrickState::from_json(&text) { + tutorial_dispatcher.dispatch(TutorialAction::InsertAfterSelected(vec![brick])); + return; + } + web_sys::console::error_1(&"Import error: unsupported JSON (not a brick or tutorial)".into()); +} + +#[cfg(target_arch = "wasm32")] +pub fn export_selected_json( + tutorial: &TutorialViewState, + export_selection: &[bool], +) -> Result<(), String> { + export_selected_bricks(tutorial, export_selection, |selected| { + let tutorial = tutorial_from_states(selected, "Exported tutorial"); + let json = tutorial.to_json(); + browser::download_json(&json, "tutorial.json").map_err(|e| format!("Export error: {e}")) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn export_selected_png( + tutorial: &TutorialViewState, + export_selection: &[bool], +) -> Result<(), String> { + export_selected_bricks(tutorial, export_selection, |selected| { + let data = tutorial_png_bytes(selected, SELECTED_TUTORIAL_WIDTH) + .map_err(|e| format!("PNG render error: {e}"))?; + browser::download_png(&data, "tutorial.png").map_err(|e| format!("PNG error: {e}")) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn export_selected_svg( + tutorial: &TutorialViewState, + export_selection: &[bool], +) -> Result<(), String> { + export_selected_bricks(tutorial, export_selection, |selected| { + let exported = tutorial_from_states(selected, "Exported tutorial"); + let svg = shared::export::tutorial_svg(&exported) + .map_err(|e| format!("SVG render error: {e}"))?; + browser::download_svg(&svg, "tutorial.svg").map_err(|e| format!("SVG error: {e}")) + }) +} diff --git a/frontend/src/app/mod.rs b/frontend/src/app/mod.rs new file mode 100644 index 0000000..f46b453 --- /dev/null +++ b/frontend/src/app/mod.rs @@ -0,0 +1,86 @@ +pub mod application; +pub mod browser; +pub mod editors; +pub mod export_selection; +pub mod history; +pub mod import_export; +pub mod theme; +pub mod toolbar; +pub mod views; + +#[cfg(target_arch = "wasm32")] +pub(crate) const APP_ROOT: &str = "flex h-screen flex-col overflow-hidden max-[900px]:relative"; +#[cfg(target_arch = "wasm32")] +pub(crate) const APP_TOOLBAR: &str = "relative z-50 flex w-full items-center justify-between gap-2.5 overflow-visible border-b border-app-border bg-app-surface px-app-gap py-2.5 max-[900px]:order-2 max-[900px]:sticky max-[900px]:bottom-0 max-[900px]:z-[60] max-[900px]:justify-center max-[900px]:border-t max-[900px]:border-b-0 max-[900px]:bg-app-surface-raised max-[900px]:pb-[calc(10px+env(safe-area-inset-bottom))]"; +#[cfg(target_arch = "wasm32")] +pub(crate) const APP_TOOLBAR_GROUP: &str = "flex items-center gap-2.5 overflow-visible"; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOLBAR_MENU: &str = "toolbar-menu"; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOLBAR_MENU_DROPDOWN: &str = "toolbar-menu__dropdown"; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOLBAR_MENU_ITEM: &str = "toolbar-menu__item"; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOLBAR_MENU_ICON: &str = "toolbar-menu__icon"; +#[cfg(target_arch = "wasm32")] +pub(crate) const APP_MAIN: &str = "flex min-h-0 flex-1 overflow-hidden max-[900px]:order-1 max-[900px]:h-[calc(100dvh-56px)] max-[900px]:pb-[56px]"; +#[cfg(target_arch = "wasm32")] +pub(crate) const APP_EDITOR_WRAP: &str = "flex min-w-0 flex-1 flex-col overflow-auto p-app-gap max-[900px]:h-full max-[900px]:flex-auto max-[900px]:pr-[calc(var(--app-gap)+var(--app-toggle-width))]"; + +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_UPLOAD: &str = include_str!("../res/upload.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_DOWNLOAD: &str = include_str!("../res/download.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_EXPORT_ALL_BRICKS: &str = include_str!("../res/zipfolder.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_EXPORT_NINEPATCH: &str = include_str!("../res/ninepatch.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_LOADING: &str = include_str!("../res/loading.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_MENU: &str = include_str!("../res/menu.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_UNDO: &str = include_str!("../res/undo.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_REDO: &str = include_str!("../res/redo.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_RESET: &str = include_str!("../res/reset.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_HELP: &str = include_str!("../res/help.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_ABOUT: &str = include_str!("../res/about.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_ADD_CUSTOM: &str = include_str!("../res/add.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_ADD_BRICK: &str = include_str!("../res/addBrick.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_DELETE: &str = include_str!("../res/delete.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_EDIT: &str = include_str!("../res/edit.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_PREVIEW: &str = include_str!("../res/preview.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_EDIT_SQUARE: &str = include_str!("../res/editsquare.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_BRICK_CATALOG: &str = include_str!("../res/brickcatalog.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_MOVE: &str = include_str!("../res/swap_vert.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_CHEVRON_RIGHT: &str = include_str!("../res/chevron_right.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_MOVE_UP: &str = include_str!("../res/keyboard_arrow_up.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_MOVE_DOWN: &str = include_str!("../res/keyboard_arrow_down.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_SUN: &str = include_str!("../res/sun.svg"); +#[cfg(target_arch = "wasm32")] +pub(crate) const ICON_MOON: &str = include_str!("../res/moon.svg"); + +#[cfg(target_arch = "wasm32")] +pub(crate) const HELP_DOCS_URL: &str = "https://catrobat.org/docs/brickdocumentation/"; +#[cfg(target_arch = "wasm32")] +pub(crate) const HELP_CONTACT_URL: &str = "https://developer.catrobat.org/pages/legal/imprint/"; +#[cfg(target_arch = "wasm32")] +pub(crate) const ABOUT_MESSAGE: &str = "About BrickCreator\n\nBrickCreator is a website built by and for Catrobat Pocket Code users, especially educators who teach Pocket Code.\n\nIt allows users to create PNG, SVG, and JSON files that can be directly used in presentations, teaching materials, and tutorials.\n\nCatrobat is an open-source platform with contributors from all over the world. For more information, visit: https://catrobat.org/about"; +#[cfg(target_arch = "wasm32")] +pub(crate) const THEME_STORAGE_KEY: &str = "theme"; diff --git a/frontend/src/app/theme.rs b/frontend/src/app/theme.rs new file mode 100644 index 0000000..0b1b194 --- /dev/null +++ b/frontend/src/app/theme.rs @@ -0,0 +1,32 @@ +#[cfg(target_arch = "wasm32")] +use super::THEME_STORAGE_KEY; + +#[cfg(target_arch = "wasm32")] +fn document() -> web_sys::Document { + gloo::utils::document() +} + +#[cfg(target_arch = "wasm32")] +pub fn load_saved_theme() -> bool { + gloo::utils::window() + .local_storage() + .ok() + .flatten() + .and_then(|s| s.get_item(THEME_STORAGE_KEY).ok().flatten()) + .map(|v| v == "light") + .unwrap_or(false) +} + +#[cfg(target_arch = "wasm32")] +pub fn apply_theme(light: bool) { + if let Some(el) = document().document_element() { + if light { + let _ = el.set_attribute("data-theme", "light"); + } else { + let _ = el.remove_attribute("data-theme"); + } + } + if let Ok(Some(storage)) = gloo::utils::window().local_storage() { + let _ = storage.set_item(THEME_STORAGE_KEY, if light { "light" } else { "dark" }); + } +} diff --git a/frontend/src/app/toolbar.rs b/frontend/src/app/toolbar.rs new file mode 100644 index 0000000..253d18b --- /dev/null +++ b/frontend/src/app/toolbar.rs @@ -0,0 +1,251 @@ +#[cfg(target_arch = "wasm32")] +use super::{ + HELP_CONTACT_URL, HELP_DOCS_URL, ICON_ABOUT, ICON_ADD_BRICK, ICON_ADD_CUSTOM, + ICON_BRICK_CATALOG, ICON_CHEVRON_RIGHT, ICON_DELETE, ICON_DOWNLOAD, ICON_EDIT, + ICON_EDIT_SQUARE, ICON_EXPORT_ALL_BRICKS, ICON_EXPORT_NINEPATCH, ICON_HELP, ICON_LOADING, + ICON_MENU, ICON_MOON, ICON_MOVE, ICON_PREVIEW, ICON_REDO, ICON_RESET, ICON_SUN, ICON_UNDO, + ICON_UPLOAD, +}; +#[cfg(target_arch = "wasm32")] +use crate::app as style; +#[cfg(target_arch = "wasm32")] +use crate::components::icon_button::IconButton; +#[cfg(target_arch = "wasm32")] +use crate::components::modal::Modal; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +fn themed_menu_icon(svg: &str) -> AttrValue { + AttrValue::from(svg.replace("fill=\"#1f1f1f\"", "fill=\"currentColor\"")) +} + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct AppToolbarProps { + pub menu_open: bool, + pub help_submenu_open: bool, + pub light: bool, + pub can_undo: bool, + pub can_redo: bool, + pub can_reset: bool, + pub all_bricks_rendering: bool, + pub ninepatch_rendering: bool, + pub on_menu: Callback, + pub on_menu_mouse_leave: Callback, + pub on_undo: Callback, + pub on_redo: Callback, + pub on_reset: Callback, + pub on_help: Callback, + pub on_help_link_click: Callback, + pub on_open_explanation: Callback, + pub on_about: Callback, + pub on_toggle_theme: Callback, + pub on_import: Callback, + pub on_enter_export_mode: Callback, + pub on_export_all_bricks_zip: Callback, + pub on_export_ninepatch_zip: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(AppToolbar)] +pub fn app_toolbar(props: &AppToolbarProps) -> Html { + html! { +
+
+
+ + if props.menu_open { +
+ + + +
+ + if props.help_submenu_open { + + } +
+ +
+ } +
+ +
+
+ + + + +
+
+ } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct IconExplanationModalProps { + pub on_close: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(IconExplanationModal)] +pub fn icon_explanation_modal(props: &IconExplanationModalProps) -> Html { + html! { + +
+ + + + + + + + + + + + + + + +
+
+ } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +struct ExplanationRowProps { + icon: Html, + text: AttrValue, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(ExplanationRow)] +fn explanation_row(props: &ExplanationRowProps) -> Html { + html! { +
+
+ {props.icon.clone()} +
+
+ {props.text.clone()} +
+
+ } +} diff --git a/frontend/src/app/views/brick_catalog_modal.rs b/frontend/src/app/views/brick_catalog_modal.rs new file mode 100644 index 0000000..54371c9 --- /dev/null +++ b/frontend/src/app/views/brick_catalog_modal.rs @@ -0,0 +1,81 @@ +#[cfg(target_arch = "wasm32")] +use crate::app::views as style; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +use crate::components::brick_view::BrickView; +#[cfg(target_arch = "wasm32")] +use crate::components::card::Card; +#[cfg(target_arch = "wasm32")] +use crate::components::editor_group::EditorGroup; +#[cfg(target_arch = "wasm32")] +use crate::components::modal::Modal; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::BrickState; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::catalog; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct BrickCatalogModalProps { + pub on_close: Callback, + pub on_add_brick: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(BrickCatalogModal)] +pub fn brick_catalog_modal(props: &BrickCatalogModalProps) -> Html { + let groups = catalog::catalog_groups(); + + html! { + +
+ { for groups.into_iter().map(|group| { + html! { +
+ +
+ { for group.entries.into_iter().map(|entry| { + let on_add_brick = props.on_add_brick.clone(); + let on_dblclick = if let Some(brick) = entry.brick.clone() { + Callback::from(move |_| on_add_brick.emit(brick.clone())) + } else { + Callback::default() + }; + + html! { + +
+ if let Some(brick) = entry.brick { + + } else { +
{ "Invalid brick" }
+ } +
+
+ } + }) } +
+
+
+ } + }) } +
+
+ } +} diff --git a/frontend/src/app/views/brick_settings/brick_settings_view.rs b/frontend/src/app/views/brick_settings/brick_settings_view.rs new file mode 100644 index 0000000..c4d8c99 --- /dev/null +++ b/frontend/src/app/views/brick_settings/brick_settings_view.rs @@ -0,0 +1,90 @@ +#[cfg(target_arch = "wasm32")] +use super::colors_group::ColorsGroup; +#[cfg(target_arch = "wasm32")] +use super::content_group::ContentGroup; +#[cfg(target_arch = "wasm32")] +use super::types_group::TypesGroup; +#[cfg(target_arch = "wasm32")] +use crate::app::views::brick_settings as style; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::brick::{BrickState, StateAction}; +#[cfg(target_arch = "wasm32")] +use shared::color::ColorScheme; +#[cfg(target_arch = "wasm32")] +use shared::types::BrickType; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct BrickSettingsViewProps { + pub brick: BrickState, + pub dispatcher: UseReducerDispatcher, + pub on_add_to_tutorial: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(BrickSettingsView)] +pub fn brick_settings_view(props: &BrickSettingsViewProps) -> Html { + let on_content_input = { + let dispatcher = props.dispatcher.clone(); + Callback::from(move |content: String| { + dispatcher.dispatch(StateAction::ChangeContent(content)); + }) + }; + + let on_brick_type_select = { + let dispatcher = props.dispatcher.clone(); + Callback::from(move |brick_type: BrickType| { + dispatcher.dispatch(StateAction::ChangeType(brick_type)); + }) + }; + + let on_color_select = { + let dispatcher = props.dispatcher.clone(); + Callback::from(move |color: ColorScheme| { + dispatcher.dispatch(StateAction::ChangeColor(color)); + }) + }; + + let on_reset = { + let dispatcher = props.dispatcher.clone(); + Callback::from(move |_: MouseEvent| { + dispatcher.dispatch(StateAction::Reset); + }) + }; + + let can_reset = props.brick != BrickState::default(); + let current = props.brick.as_brick(); + let content_val = current.content.clone(); + let brick_type = props.brick.get_type(); + let color_name = current.color_scheme.name.clone(); + let selected_color = current.color_scheme.clone(); + + html! { +
+ +
+
+ +
+
+ +
+
+
+ } +} diff --git a/frontend/src/app/views/brick_settings/colors_group.rs b/frontend/src/app/views/brick_settings/colors_group.rs new file mode 100644 index 0000000..da17b0b --- /dev/null +++ b/frontend/src/app/views/brick_settings/colors_group.rs @@ -0,0 +1,300 @@ +#[cfg(target_arch = "wasm32")] +use crate::app::views::brick_settings as style; +#[cfg(target_arch = "wasm32")] +use crate::app::views::view::color_view::ColorView; +#[cfg(target_arch = "wasm32")] +use crate::components::context_menu::{ContextMenu, ContextMenuItem}; +#[cfg(target_arch = "wasm32")] +use crate::components::custom_color_modal::CustomColorModal; +#[cfg(target_arch = "wasm32")] +use crate::components::editor_group::EditorGroup; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::color::{ + BrickColorModel, ColorModel, CustomColorDraft, CustomColorEditState, +}; +#[cfg(target_arch = "wasm32")] +use crate::interfaces::color_storage::{load_saved_custom_colors, persist_saved_custom_colors}; +#[cfg(target_arch = "wasm32")] +use shared::color::ColorScheme; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct ColorsGroupProps { + pub selected_name: String, + pub on_select: Callback, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(ColorsGroup)] +pub fn colors_group(props: &ColorsGroupProps) -> Html { + let model = use_state(|| BrickColorModel::with_custom_colors(load_saved_custom_colors())); + let saved_custom_colors = use_state(load_saved_custom_colors); + let modal_open = use_state(|| false); + let draft = use_state(CustomColorDraft::default); + let editing_color = use_state(|| Option::::None); + let error_message = use_state(|| Option::::None); + let context_menu = use_state(|| Option::<(String, i32, i32)>::None); + + let colors = (*model).all_colors(); + let custom_color_names = colors + .iter() + .map(|color| color.name.clone()) + .filter(|name| { + !model + .default_colors() + .iter() + .any(|entry| entry.name == *name) + }) + .collect::>(); + + let open_modal = { + let modal_open = modal_open.clone(); + let draft = draft.clone(); + let editing_color = editing_color.clone(); + let error_message = error_message.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |_| { + draft.set(CustomColorDraft::default()); + editing_color.set(None); + error_message.set(None); + context_menu.set(None); + modal_open.set(true); + }) + }; + + let close_modal = { + let modal_open = modal_open.clone(); + let editing_color = editing_color.clone(); + let error_message = error_message.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |_| { + editing_color.set(None); + error_message.set(None); + context_menu.set(None); + modal_open.set(false); + }) + }; + + let close_context_menu = { + let context_menu = context_menu.clone(); + Callback::from(move |_e: MouseEvent| context_menu.set(None)) + }; + + let on_draft_change = { + let draft = draft.clone(); + Callback::from(move |next: CustomColorDraft| draft.set(next)) + }; + + let on_save = { + let model = model.clone(); + let saved_custom_colors = saved_custom_colors.clone(); + let draft = draft.clone(); + let editing_color = editing_color.clone(); + let error_message = error_message.clone(); + let modal_open = modal_open.clone(); + let context_menu = context_menu.clone(); + let on_select = props.on_select.clone(); + Callback::from(move |_| { + let new_color = (*draft).to_scheme(); + if new_color.name.is_empty() { + error_message.set(Some( + "Give the custom color a name before saving.".to_string(), + )); + return; + } + + let mut next_model = (*model).clone(); + let mut next_saved = (*saved_custom_colors).clone(); + + if let Some(editing) = &*editing_color { + if !next_model.replace_custom_color(&editing.original_name, new_color.clone()) { + error_message.set(Some( + "That color name already exists. Pick a unique name.".to_string(), + )); + return; + } + + let mut updated_saved = false; + let was_saved = next_saved + .iter() + .any(|entry| entry.name == editing.original_name); + + if draft.save_for_later || was_saved { + for saved in &mut next_saved { + if saved.name == editing.original_name { + *saved = new_color.clone(); + updated_saved = true; + } + } + } + + if !draft.save_for_later { + let before = next_saved.len(); + next_saved.retain(|entry| entry.name != editing.original_name); + updated_saved |= next_saved.len() != before; + } else if !updated_saved { + next_saved.push(new_color.clone()); + updated_saved = true; + } + + if updated_saved { + persist_saved_custom_colors(&next_saved); + saved_custom_colors.set(next_saved); + } + } else { + if !next_model.add_custom_color(new_color.clone()) { + error_message.set(Some( + "That color name already exists. Pick a unique name.".to_string(), + )); + return; + } + + if draft.save_for_later { + next_saved.push(new_color.clone()); + persist_saved_custom_colors(&next_saved); + saved_custom_colors.set(next_saved); + } + } + + on_select.emit(new_color.clone()); + model.set(next_model); + editing_color.set(None); + error_message.set(None); + context_menu.set(None); + modal_open.set(false); + }) + }; + + let on_custom_context_menu = { + let context_menu = context_menu.clone(); + Callback::from(move |(name, e): (String, MouseEvent)| { + e.prevent_default(); + context_menu.set(Some((name, e.client_x(), e.client_y()))); + }) + }; + + let on_delete_custom = { + let model = model.clone(); + let saved_custom_colors = saved_custom_colors.clone(); + let on_select = props.on_select.clone(); + let selected_name = props.selected_name.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + let Some((name, _, _)) = (*context_menu).clone() else { + return; + }; + let mut next_model = (*model).clone(); + if !next_model.remove_custom_color(&name) { + return; + } + + if selected_name == name { + if let Some(fallback) = next_model.default_colors().first().cloned() { + on_select.emit(fallback); + } + } + + let mut next_saved = (*saved_custom_colors).clone(); + let saved_len = next_saved.len(); + next_saved.retain(|color| color.name != name); + if next_saved.len() != saved_len { + persist_saved_custom_colors(&next_saved); + saved_custom_colors.set(next_saved); + } + + model.set(next_model); + context_menu.set(None); + }) + }; + + let on_edit_custom = { + let model = model.clone(); + let saved_custom_colors = saved_custom_colors.clone(); + let draft = draft.clone(); + let editing_color = editing_color.clone(); + let error_message = error_message.clone(); + let modal_open = modal_open.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + let Some((name, _, _)) = (*context_menu).clone() else { + return; + }; + let Some(color) = model + .custom_colors() + .iter() + .find(|entry| entry.name == name) + .cloned() + else { + context_menu.set(None); + return; + }; + + let is_saved = saved_custom_colors.iter().any(|entry| entry.name == name); + draft.set(CustomColorDraft { + name: color.name.clone(), + color: color.color, + shade: color.shade, + border: color.border, + text: color.text, + save_for_later: is_saved, + }); + editing_color.set(Some(CustomColorEditState { + original_name: name, + })); + error_message.set(None); + context_menu.set(None); + modal_open.set(true); + }) + }; + + let keep_menu_open = Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + }); + + let is_editing = editing_color.is_some(); + html! { + +
+
+ +
+ if let Some((_, x, y)) = &*context_menu { + + + {"Edit"} + + + {"Delete"} + + + } +
+ if *modal_open { + + } +
+ } +} diff --git a/frontend/src/app/views/brick_settings/content_group.rs b/frontend/src/app/views/brick_settings/content_group.rs new file mode 100644 index 0000000..c438920 --- /dev/null +++ b/frontend/src/app/views/brick_settings/content_group.rs @@ -0,0 +1,220 @@ +#[cfg(target_arch = "wasm32")] +use super::ICON_ADD_BRICK; +#[cfg(target_arch = "wasm32")] +use crate::app::ICON_RESET; +#[cfg(target_arch = "wasm32")] +use crate::app::views::brick_settings as style; +#[cfg(target_arch = "wasm32")] +use crate::components::context_menu::{ContextMenu, ContextMenuItem}; +#[cfg(target_arch = "wasm32")] +use crate::components::editor_group::EditorGroup; +#[cfg(target_arch = "wasm32")] +use crate::components::icon_button::IconButton; +#[cfg(target_arch = "wasm32")] +use shared::brick::base::{DROP_MARKER, EMPTY_BRICK_HINT, VARIABLE_MARKER}; +#[cfg(target_arch = "wasm32")] +use yew::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[derive(Properties, PartialEq)] +pub struct ContentGroupProps { + pub content: String, + pub on_content_input: Callback, + pub on_add_to_tutorial: Callback, + pub on_reset: Callback, + pub can_reset: bool, +} + +#[cfg(target_arch = "wasm32")] +#[function_component(ContentGroup)] +pub fn content_group(props: &ContentGroupProps) -> Html { + let textarea_ref = use_node_ref(); + let selection = use_state(|| (0_u32, 0_u32)); + let context_menu = use_state(|| Option::<(i32, i32)>::None); + + let close_context_menu_click = { + let context_menu = context_menu.clone(); + Callback::from(move |_e: MouseEvent| context_menu.set(None)) + }; + + let sync_selection = { + let textarea_ref = textarea_ref.clone(); + let selection = selection.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |_| { + if let Some(input) = textarea_ref.cast::() { + let start = input.selection_start().ok().flatten().unwrap_or(0); + let end = input.selection_end().ok().flatten().unwrap_or(start); + selection.set((start, end)); + if start >= end { + context_menu.set(None); + } + } + }) + }; + + let on_input = { + let on_content_input = props.on_content_input.clone(); + let sync_selection = sync_selection.clone(); + Callback::from(move |e: InputEvent| { + if let Some(input) = e.target_dyn_into::() { + on_content_input.emit(input.value()); + } + sync_selection.emit(()); + }) + }; + + let on_select = { + let sync_selection = sync_selection.clone(); + Callback::from(move |_e: Event| { + sync_selection.emit(()); + }) + }; + + let on_keyup = { + let sync_selection = sync_selection.clone(); + Callback::from(move |_e: KeyboardEvent| { + sync_selection.emit(()); + }) + }; + + let on_mouseup = { + let sync_selection = sync_selection.clone(); + Callback::from(move |_e: MouseEvent| { + sync_selection.emit(()); + }) + }; + + let on_context_menu = { + let textarea_ref = textarea_ref.clone(); + let selection = selection.clone(); + let context_menu = context_menu.clone(); + Callback::from(move |e: MouseEvent| { + if let Some(input) = textarea_ref.cast::() { + let start = input.selection_start().ok().flatten().unwrap_or(0); + let end = input.selection_end().ok().flatten().unwrap_or(start); + selection.set((start, end)); + if start < end { + e.prevent_default(); + context_menu.set(Some((e.client_x(), e.client_y()))); + } else { + context_menu.set(None); + } + } + }) + }; + + let wrap_selection = { + let textarea_ref = textarea_ref.clone(); + let selection = selection.clone(); + let context_menu = context_menu.clone(); + let on_content_input = props.on_content_input.clone(); + let content = props.content.clone(); + move |marker: &'static str| { + let (start, end) = *selection; + if start >= end { + return; + } + + let start = start as usize; + let end = end as usize; + let Some(selected) = content.get(start..end) else { + return; + }; + + let updated = format!( + "{}{}{}{}{}", + &content[..start], + marker, + selected, + marker, + &content[end..] + ); + let caret_start = (start + marker.len()) as u32; + let caret_end = (end + marker.len()) as u32; + + if let Some(input) = textarea_ref.cast::() { + input.set_value(&updated); + let _ = input.focus(); + let _ = input.set_selection_range(caret_start, caret_end); + } + + selection.set((caret_start, caret_end)); + context_menu.set(None); + on_content_input.emit(updated); + } + }; + + let keep_focus_on_menu = Callback::from(move |e: MouseEvent| { + e.prevent_default(); + }); + + let on_variable = { + let wrap_selection = wrap_selection.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + wrap_selection(VARIABLE_MARKER); + }) + }; + + let on_dropdown = { + let wrap_selection = wrap_selection.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + wrap_selection(DROP_MARKER); + }) + }; + + let has_selection = { + let (start, end) = *selection; + start < end + }; + + html! { + + + + + }} + > +
+