Skip to main content

blockworx_web/
editor.rs

1//! The in-place editor: the field the kernel asked for, run over the diagram.
2//!
3//! The editor is the front end's. It keeps the draft, the caret and the
4//! selection, and says only how the edit ended — a commit, a cancel, or a Tab
5//! that steps the cycle on. The kernel hears no keystroke, and leaves out of
6//! the diagram the text this field covers.
7
8use blockworx_canvas2d::css_color;
9use blockworx_geom::{Align, WorldPx};
10use blockworx_kernel::Event;
11use blockworx_paint::edit::{EDITOR_BORDER, EDITOR_PAD, EDITOR_ROUNDING};
12use blockworx_paint::{EditField, FontChoice, TextEvent, Vantage};
13use dioxus::prelude::*;
14use dioxus::web::WebEventExt as _;
15use wasm_bindgen::JsCast as _;
16use web_sys::{HtmlInputElement, HtmlTextAreaElement};
17
18use crate::shell::Shell;
19
20/// Whether the field has already said how it ended. A cancel takes the focus
21/// away with it, and the blur that follows must not commit what Escape just
22/// dropped.
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24enum Ending {
25    Open,
26    Said,
27}
28
29/// The field the last call asked for. Keyed by the field's id where it is
30/// rendered, so opening a different editor mounts a fresh draft.
31#[component]
32pub fn InPlaceEditor(
33    shell: Shell,
34    field: EditField,
35    vantage: Vantage,
36    typeface: FontChoice,
37) -> Element {
38    let shell = use_hook(|| CopyValue::new(shell));
39    let mut draft = use_signal(|| field.text.clone());
40    let mut ending = use_signal(|| Ending::Open);
41    let id = field.id;
42    let tab_cycle = field.tab_cycle;
43    let multiline = field.multiline;
44    let selecting = Selecting::of(&field);
45
46    let mut ends = move |event: TextEvent| {
47        if *ending.peek() == Ending::Open {
48            ending.set(Ending::Said);
49            let shell = shell.read();
50            shell.say(Event::Text(event));
51            shell.takes_keyboard();
52        }
53    };
54    let typed = move |event: KeyboardEvent| {
55        // The canvas reads the keyboard for the drawing; while a field has it,
56        // every keystroke is the field's.
57        event.stop_propagation();
58        match event.key() {
59            Key::Escape => ends(TextEvent::Cancelled { id }),
60            Key::Enter if !multiline => ends(TextEvent::Committed {
61                id,
62                text: draft.peek().clone(),
63            }),
64            Key::Tab if tab_cycle => {
65                event.prevent_default();
66                ends(TextEvent::TabPressed {
67                    id,
68                    text: draft.peek().clone(),
69                });
70            }
71            _ => {}
72        }
73    };
74    let left = move |_| {
75        ends(TextEvent::Committed {
76            id,
77            text: draft.peek().clone(),
78        });
79    };
80    let opened = move |event: MountedEvent| {
81        if let Some(element) = event.try_as_web_event() {
82            focus(&element, selecting);
83        }
84    };
85    let style = style_of(&field, vantage, typeface);
86    let hint = field
87        .hint
88        .as_ref()
89        .map_or_else(String::new, |it| it.to_string());
90    let limit = field.char_limit.map(|limit| limit.to_string());
91
92    rsx! {
93        if multiline {
94            textarea {
95                class: "bw-field",
96                style: "{style}",
97                maxlength: limit,
98                placeholder: "{hint}",
99                value: "{draft}",
100                onmounted: opened,
101                oninput: move |event| draft.set(event.value()),
102                onkeydown: typed,
103                onfocusout: left,
104            }
105        } else {
106            input {
107                class: "bw-field",
108                style: "{style}",
109                r#type: "text",
110                maxlength: limit,
111                placeholder: "{hint}",
112                value: "{draft}",
113                onmounted: opened,
114                oninput: move |event| draft.set(event.value()),
115                onkeydown: typed,
116                onfocusout: left,
117            }
118        }
119    }
120}
121
122/// Whether the field opens on its whole text selected, so typing replaces it.
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124enum Selecting {
125    All,
126    Nothing,
127}
128
129impl Selecting {
130    /// A field opens on its whole text where the request asks for it, and a
131    /// cycle editor always does: Tab steps onto a field whose text the next
132    /// keystroke is meant to replace.
133    fn of(field: &EditField) -> Self {
134        if field.select_all_on_focus || field.tab_cycle {
135            Self::All
136        } else {
137            Self::Nothing
138        }
139    }
140}
141
142fn focus(element: &web_sys::Element, selecting: Selecting) {
143    if let Some(field) = element.dyn_ref::<HtmlTextAreaElement>() {
144        let _ = field.focus();
145        if selecting == Selecting::All {
146            field.select();
147        }
148    } else if let Some(field) = element.dyn_ref::<HtmlInputElement>() {
149        let _ = field.focus();
150        if selecting == Selecting::All {
151            field.select();
152        }
153    }
154}
155
156/// The field as the diagram has it: where the kernel put it, turned by the
157/// mark's own angle about the rect's centre, in the face and at the size the
158/// run was measured in. The pad, the ring and the rounding are the ones the
159/// kernel sized the field by, scaled by the camera like everything else.
160fn style_of(field: &EditField, vantage: Vantage, typeface: FontChoice) -> String {
161    let rect = field.rect;
162    let font = vantage.remap_font(&field.font);
163    let pad = (
164        vantage.remap_len(WorldPx::new(EDITOR_PAD.x)),
165        vantage.remap_len(WorldPx::new(EDITOR_PAD.y)),
166    );
167    let colors = field.colors;
168    [
169        format!("left:{}px", rect.min.x),
170        format!("top:{}px", rect.min.y),
171        format!("width:{}px", rect.width()),
172        // A text area grows with its draft from the height it opened on.
173        if field.multiline {
174            format!("min-height:{}px", rect.height())
175        } else {
176            format!("height:{}px", rect.height())
177        },
178        format!("transform:rotate({}deg)", field.angle.degrees()),
179        // The shaper has one face, so a run the recorder measured is in that
180        // face whatever family it asked for — and so is the draft over it.
181        format!("font-family:{}", typeface.family_name()),
182        format!("font-size:{}px", font.size),
183        format!("text-align:{}", aligned(field.align.x())),
184        format!("color:{}", css_color(colors.text)),
185        format!("background:{}", css_color(colors.background)),
186        format!("caret-color:{}", css_color(colors.caret)),
187        format!("--bw-selection:{}", css_color(colors.selection)),
188        format!(
189            "border:{}px solid {}",
190            vantage.remap_len(EDITOR_BORDER),
191            css_color(colors.border),
192        ),
193        format!("border-radius:{}px", vantage.remap_len(EDITOR_ROUNDING)),
194        format!("padding:{}px {}px", pad.1, pad.0),
195    ]
196    .join(";")
197}
198
199fn aligned(align: Align) -> &'static str {
200    match align {
201        Align::Min => "left",
202        Align::Center => "center",
203        Align::Max => "right",
204    }
205}
206
207/// Hand the browser the bundled faces, under the names they are asked for.
208/// The diagram needs none of this — its glyphs are outlines the backend fills
209/// — but the field over it is the browser's own text, and it is drawn in the
210/// same face as what it covers.
211pub fn register_faces() {
212    let Some(fonts) = crate::shell::window()
213        .and_then(|window| window.document())
214        .map(|document| document.fonts())
215    else {
216        return;
217    };
218    for typeface in FontChoice::ALL {
219        let Ok(face) =
220            web_sys::FontFace::new_with_u8_array(typeface.family_name(), typeface.bytes())
221        else {
222            tracing::error!("the browser refused the {} face", typeface.family_name());
223            continue;
224        };
225        let _ = face.load();
226        let _ = fonts.add(&face);
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    /// Every horizontal alignment names a CSS keyword of its own — two that
235    /// shared one would silently centre a label the kernel anchored.
236    #[test]
237    fn every_alignment_has_its_own_keyword() {
238        let mut named = [Align::Min, Align::Center, Align::Max].map(aligned);
239        named.sort_unstable();
240        let count = named.len();
241        let mut unique = named.to_vec();
242        unique.dedup();
243        assert_eq!(unique.len(), count);
244    }
245}