worldwideportal/src/editor_view.rs

350 lines
13 KiB
Rust
Raw Normal View History

2024-10-11 10:02:38 +11:00
use std::{ops::Deref, rc::Rc};
2024-10-11 10:02:38 +11:00
use crate::{FrameId, GlobalLayoutState, GlobalMemoCell, PanelDirection, SplitPanel};
use anyhow::Error;
2024-10-11 10:02:38 +11:00
use editor_area::EditorArea;
2024-10-05 22:54:48 +10:00
use itertools::Itertools;
2024-10-11 10:02:38 +11:00
use wasm_bindgen::{closure::Closure, JsCast};
use web_sys::{window, HtmlInputElement, KeyboardEvent};
use yew::{
2024-10-11 10:02:38 +11:00
function_component, html, use_effect_with, use_node_ref, use_state, use_state_eq, AttrValue,
Callback, Html, Properties, UseStateHandle,
};
2024-10-11 10:02:38 +11:00
mod editor_area;
#[derive(Properties, PartialEq, Clone)]
pub struct EditorViewProps {
pub frame: FrameId,
pub global_memo: GlobalMemoCell,
pub global_layout: UseStateHandle<Rc<GlobalLayoutState>>,
}
2024-10-05 22:54:48 +10:00
#[derive(Properties, PartialEq, Clone)]
pub struct EditorViewDetailProps {
pub frame: FrameId,
pub global_memo: GlobalMemoCell,
pub global_layout: UseStateHandle<Rc<GlobalLayoutState>>,
pub editor_state: UseStateHandle<Rc<EditorViewState>>,
}
fn close_editor(frame: &FrameId, global_layout: &UseStateHandle<Rc<GlobalLayoutState>>) {
let mut gl_new: GlobalLayoutState = global_layout.as_ref().clone();
gl_new.frame_views.remove(frame);
global_layout.set(gl_new.into());
}
2024-10-05 22:54:48 +10:00
#[derive(Clone, PartialEq)]
pub struct EditorViewState {
error_msg: Option<AttrValue>,
available_files: Vec<AttrValue>,
2024-10-05 22:54:48 +10:00
open_file: AttrValue,
2024-10-11 10:02:38 +11:00
show_create_dialog: bool,
show_delete_dialog: Option<String>,
}
2024-10-05 22:54:48 +10:00
fn fetch_initial_editor_state_or_fail() -> anyhow::Result<EditorViewState> {
let win = window().ok_or_else(|| Error::msg("Can't get window"))?;
2024-10-05 22:54:48 +10:00
let local = win
.local_storage()
.map_err(|_| Error::msg("Error retrieving localStorage"))?
.ok_or_else(|| Error::msg("Local storage not available"))?;
2024-10-05 22:54:48 +10:00
let n_keys = local
.length()
.map_err(|_| Error::msg("localStorage broken"))?;
let mut available_files: Vec<AttrValue> = vec![];
for i in 0..n_keys {
if let Some(key) = local
.key(i)
.map_err(|_| Error::msg("localStorage broken"))?
{
match key.strip_prefix("scriptfile_") {
None => {}
Some(filename) => {
available_files.push(filename.to_owned().into());
}
}
}
}
let init_str: AttrValue = "init.lua".into();
if !available_files.contains(&init_str) {
available_files.push(init_str);
local
.set(
"scriptfile_init.lua",
"-- Put any code to run on every client load here.\n",
)
.map_err(|_| Error::msg("localStorage broken"))?;
}
Ok(EditorViewState {
error_msg: None,
2024-10-05 22:54:48 +10:00
available_files,
open_file: "init.lua".into(),
2024-10-11 10:02:38 +11:00
show_create_dialog: false,
show_delete_dialog: None,
})
}
2024-10-05 22:54:48 +10:00
fn fetch_initial_editor_state() -> Rc<EditorViewState> {
match fetch_initial_editor_state_or_fail() {
2024-10-05 22:54:48 +10:00
Ok(s) => s.into(),
Err(e) => EditorViewState {
error_msg: Some(format!("Can't load editor data: {}", e).into()),
available_files: vec![],
2024-10-05 22:54:48 +10:00
open_file: "init.lua".into(),
2024-10-11 10:02:38 +11:00
show_create_dialog: false,
show_delete_dialog: None,
2024-10-05 22:54:48 +10:00
}
.into(),
}
}
2024-10-11 10:02:38 +11:00
fn load_file_contents(file: &str) -> anyhow::Result<String> {
let win = window().ok_or_else(|| Error::msg("Can't get window"))?;
let local = win
.local_storage()
.map_err(|_| Error::msg("Error retrieving localStorage"))?
.ok_or_else(|| Error::msg("Local storage not available"))?;
Ok(local
.get(&format!("scriptfile_{}", file))
.map_err(|_| Error::msg("Error retrieving localStorage"))?
.unwrap_or_else(|| "".to_owned()))
}
pub fn try_run_script(script: &str, global_memo: &GlobalMemoCell) -> anyhow::Result<()> {
let script = load_file_contents(script)?;
global_memo
.lua_engine
.borrow_mut()
.execute(&script)
.map_err(Error::msg)?;
Ok(())
}
fn run_script<F>(script: &str, global_memo: &GlobalMemoCell, set_err: Rc<F>)
where
F: Fn(Option<&str>),
{
match try_run_script(script, global_memo) {
Ok(()) => {
set_err(None);
}
Err(e) => {
set_err(Some(&format!("Script error: {}", e)));
}
}
}
2024-10-11 10:02:38 +11:00
fn show_create_dialog(state: &UseStateHandle<Rc<EditorViewState>>) {
let mut new_state: EditorViewState = (*state.deref().deref()).clone();
new_state.show_create_dialog = true;
state.set(new_state.into());
}
#[function_component(EditorNav)]
2024-10-05 22:54:48 +10:00
fn editor_nav(props: &EditorViewDetailProps) -> Html {
let global_memo = props.global_memo.clone();
let global_layout = props.global_layout.clone();
let frame = props.frame.clone();
let current_script = props.editor_state.open_file.clone();
let set_err_state = props.editor_state.clone();
let set_err = Rc::new(move |msg: Option<&str>| {
let mut new_state = (*set_err_state.as_ref()).clone();
new_state.error_msg = msg.map(|v| AttrValue::from(String::from(v)));
set_err_state.set(new_state.into());
});
2024-10-11 10:02:38 +11:00
let editor_state = props.editor_state.clone();
html! {
<div class="editornav">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<button class="btn" aria-label="Run" title="Run"
onclick={move |_ev| run_script(&current_script, &global_memo, set_err.clone())}>
<i class="bi bi-file-earmark-play"></i></button>
2024-10-11 10:02:38 +11:00
<button class="btn" aria-label="New file" title="New file"
onclick={move |_ev| show_create_dialog(&editor_state) }
><i class="bi bi-file-earmark-plus"></i></button>
<div class="flex-fill"/>
<button class="btn" onclick={move |_ev| close_editor(&frame, &global_layout)} aria-label="Close Editor" title="Close editor"><i class="bi bi-arrow-return-left"></i></button>
</nav>
2024-10-05 22:54:48 +10:00
<ul class="p-2 list-group">
{props
.editor_state
.available_files
.iter()
.map(|f| {
2024-10-09 22:33:53 +11:00
let mut classes = vec!["list-group-item", "pe-auto"];
2024-10-05 22:54:48 +10:00
let mut aria_current = None;
if *f == props.editor_state.open_file {
aria_current = Some("true");
classes.push("active");
}
2024-10-09 22:33:53 +11:00
html! { <li aria-current={aria_current} tabindex={0}
class={classes.iter().join(" ")}
style="cursor: pointer"
>{f}</li>}
2024-10-05 22:54:48 +10:00
})
.collect::<Vec<Html>>()
}
</ul>
</div>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct ErrorBarProps {
pub msg: AttrValue,
pub dismiss: Callback<()>,
}
#[function_component(ErrorBar)]
fn error_bar(props: &ErrorBarProps) -> Html {
let props = props.clone();
html! {
<div class="errorbar alert alert-danger alert-dismissible" role="alert">
<span class="errorbar-msg">{props.msg.clone()}</span>
<button class="btn-close" aria-label="Close" onclick={move |_ev| props.dismiss.emit(())}></button>
</div>
}
}
2024-10-11 10:02:38 +11:00
fn close_modals(state: &UseStateHandle<Rc<EditorViewState>>) {
let mut new_state = (*state.deref().deref()).clone();
new_state.show_create_dialog = false;
new_state.show_delete_dialog = None;
state.set(new_state.into());
}
fn create_script(state: &UseStateHandle<Rc<EditorViewState>>) {
let mut scriptname = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("newscriptname"))
.map(|el| el.unchecked_into::<HtmlInputElement>().value())
.unwrap_or_default();
if scriptname.is_empty() {
scriptname = "untitled.lua".to_owned();
}
let mut new_state = (*state.deref().deref()).clone();
let scriptname: AttrValue = scriptname.into();
if new_state.available_files.contains(&scriptname) {
new_state.error_msg = Some("A script with that name already exists".into());
}
new_state.available_files.push(scriptname.clone());
new_state.open_file = scriptname;
new_state.show_create_dialog = false;
state.set(new_state.into());
}
#[function_component(CodeEditorView)]
pub fn editor_view(props: &EditorViewProps) -> Html {
2024-10-05 22:54:48 +10:00
let editor_state: UseStateHandle<Rc<EditorViewState>> =
use_state_eq(fetch_initial_editor_state);
let detail_props: EditorViewDetailProps = EditorViewDetailProps {
frame: props.frame.clone(),
global_memo: props.global_memo.clone(),
global_layout: props.global_layout.clone(),
editor_state: editor_state.clone(),
};
2024-10-09 22:33:53 +11:00
let current_script = editor_state.open_file.clone();
let set_err_state = editor_state.clone();
let set_err = Rc::new(move |msg: Option<&str>| {
let mut new_state = (*set_err_state.as_ref()).clone();
new_state.error_msg = msg.map(|v| AttrValue::from(String::from(v)));
set_err_state.set(new_state.into());
});
let editor_ref = use_node_ref();
let global_layout = props.global_layout.clone();
let frame = props.frame.clone();
2024-10-11 10:02:38 +11:00
type KbClosure = Closure<dyn FnMut(KeyboardEvent)>;
let editor_closure: UseStateHandle<Option<Rc<KbClosure>>> = use_state(|| None);
2024-10-09 22:33:53 +11:00
let editor_ref_eff = editor_ref.clone();
let global_memo = props.global_memo.clone();
2024-10-11 10:02:38 +11:00
use_effect_with(current_script.clone(), move |_| {
2024-10-09 22:33:53 +11:00
if let Some(editor_node) = editor_ref_eff.get() {
let closure = Closure::new(move |ev: KeyboardEvent| {
if ev.code() == "Enter" && ev.get_modifier_state("Control") {
run_script(&current_script, &global_memo, set_err.clone());
ev.prevent_default();
} else if ev.code() == "Escape" {
close_editor(&frame, &global_layout);
}
});
let _ = editor_node
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
2024-10-11 10:02:38 +11:00
let closure: Rc<Closure<dyn FnMut(KeyboardEvent)>> = closure.into();
editor_closure.set(Some(closure.clone()));
let cleanup: Box<dyn FnOnce()> = Box::new(move || {
let _ = editor_node.remove_event_listener_with_callback(
"keydown",
(*closure.as_ref()).as_ref().unchecked_ref(),
);
});
return cleanup;
2024-10-09 22:33:53 +11:00
}
2024-10-11 10:02:38 +11:00
Box::new(|| {})
2024-10-09 22:33:53 +11:00
});
2024-10-11 10:02:38 +11:00
let state_modalclose1 = editor_state.clone();
let state_modalclose2 = editor_state.clone();
let state_createscript = editor_state.clone();
html! {
2024-10-09 22:33:53 +11:00
<div class="w-100 h-100" ref={editor_ref.clone()}>
2024-10-11 10:02:38 +11:00
{if editor_state.show_create_dialog {
html! { <>
<div class="modal-backdrop fade show"/>
<div class="modal" tabindex="-1" aria-modal="true" role="dialog" style="display: block">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{"Create script"}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
onclick={move |_ev| close_modals(&state_modalclose1) }></button>
</div>
<div class="modal-body">
<p class="d-flex flex-row"><label for="newscriptname" class="pe-1">{"New script name: "}</label>
<input class="flex-grow-1" type="text" id="newscriptname" tabindex="1"/></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
onclick={move |_ev| close_modals(&state_modalclose2) }>{"Close"}</button>
<button type="button" class="btn btn-primary" onclick={move |_ev| create_script(&state_createscript) }>{"Create script"}</button>
</div>
</div>
</div>
</div>
</>}
} else {
html! { <></> }
}}
{match editor_state.error_msg.as_ref() {
None => { html! { <></> }}
2024-10-05 22:54:48 +10:00
Some(msg) => {
let editor_state = editor_state.clone();
html! {
<ErrorBar msg={msg.clone()}
2024-10-05 22:54:48 +10:00
dismiss={move |()| editor_state.set(EditorViewState {
error_msg: None,
2024-10-05 22:54:48 +10:00
..((*editor_state.as_ref()).clone())
}.into())
}/>
}
}
}}
<SplitPanel direction={PanelDirection::Horizontal}
2024-10-05 22:54:48 +10:00
first={html!{<EditorNav ..detail_props.clone() />}}
second={html!{<EditorArea ..detail_props.clone()/>}}
/>
2024-10-09 22:33:53 +11:00
</div>
}
}