Skip to main content

blockworx_web/
canvas.rs

1//! The diagram's element, and everything the browser tells it.
2//!
3//! Every handler here does one thing: it turns a DOM event into a sample or an
4//! [`Event`] and hands it to the shell. What a drag *means* — a pan, a tool's
5//! gesture, a click — is the backend's [`Reader`](blockworx_canvas2d::Reader)
6//! and the kernel's, not this element's.
7
8use blockworx_canvas2d::input::{Edge, Phase, Sample, chord_of, dom};
9use blockworx_kernel::Event;
10use blockworx_paint::Button;
11use blockworx_tools::commands::CommandId;
12use blockworx_tools::tool::Action;
13use dioxus::prelude::*;
14use dioxus::web::WebEventExt as _;
15use wasm_bindgen::JsCast as _;
16use wasm_bindgen::prelude::Closure;
17use web_sys::{HtmlCanvasElement, KeyboardEvent, PointerEvent};
18
19use crate::exchange::Zone;
20use crate::shell::{Escaped, Shell, types_into};
21
22/// The canvas, filling the diagram's part of the page.
23///
24/// `tabindex` so it can hold the keyboard, which a press on the diagram hands
25/// it. `touch-action: none` and the rest of `.bw-canvas` are what stop the
26/// browser claiming a gesture the editor is the recognizer for.
27#[component]
28pub fn Canvas(shell: Shell) -> Element {
29    // One `Copy` handle for the handlers below: the shell is an `Rc` value,
30    // which each `move` closure would otherwise take for itself.
31    let shell = use_hook(|| CopyValue::new(shell));
32    rsx! {
33        canvas {
34            class: "bw-canvas absolute inset-0 h-full w-full touch-none select-none outline-none \
35                    transition-[filter] group-data-[viewing=true]:saturate-50",
36            tabindex: "0",
37            onmounted: move |event| {
38                if let Some(canvas) = event
39                    .try_as_web_event()
40                    .and_then(|element| element.dyn_into::<HtmlCanvasElement>().ok())
41                {
42                    shell.read().mounted(canvas);
43                }
44            },
45            onresize: move |_| shell.read().resized(),
46            // The right button pans; the browser's image menu would take the
47            // gesture from under it.
48            oncontextmenu: move |event| event.prevent_default(),
49            // A document dropped on the diagram is embedded as a block of it;
50            // a picture is placed. Both halves have to refuse the browser its
51            // own answer — opening the file in place of the page — and it
52            // only offers the drop where the drag was taken.
53            ondragover: move |event| {
54                if let Some(native) = event.try_as_web_event() {
55                    native.prevent_default();
56                    dragged(&shell.read(), &native);
57                }
58            },
59            ondrop: move |event| {
60                let Some(native) = event.try_as_web_event() else {
61                    return;
62                };
63                native.prevent_default();
64                dragged(&shell.read(), &native);
65                if let Some(file) = crate::exchange::carried_by(&native) {
66                    crate::exchange::drops(&shell.read(), Zone::Diagram, &file);
67                }
68            },
69            onpointerdown: move |event| {
70                if let Some(native) = event.try_as_web_event() {
71                    // The middle button drives the camera. `prevent_default`
72                    // takes the autoscroll the platform would start on it;
73                    // the paste X11 binds to the same button is raised on the
74                    // button coming back *up* and is out of reach here, so
75                    // the shell refuses that one itself (`Pasting`).
76                    if dom::button_of(native.button()) == Some(Button::Middle) {
77                        native.prevent_default();
78                        shell.read().provoked();
79                    }
80                    // A press on the diagram is unambiguous intent to edit,
81                    // so it dismisses the navigator — and selects, in the
82                    // one gesture.
83                    shell.read().works();
84                    shell.read().grabs(native.pointer_id());
85                    pointed(&shell.read(), &native, Phase::Down);
86                }
87            },
88            onpointermove: move |event| pointed_by(&shell.read(), &event, Phase::Moved),
89            onpointerup: move |event| pointed_by(&shell.read(), &event, Phase::Up),
90            onpointerleave: move |event| pointed_by(&shell.read(), &event, Phase::Left),
91            onpointercancel: move |event| pointed_by(&shell.read(), &event, Phase::Cancelled),
92            onwheel: move |event| {
93                let Some(native) = event.try_as_web_event() else {
94                    return;
95                };
96                // A bare wheel zooms the drawing and a ctrl-held one is the
97                // trackpad's pinch: neither is the page's to scroll or zoom.
98                native.prevent_default();
99                let shell = shell.read();
100                if let Some(origin) = shell.origin() {
101                    shell.sampled(Sample::Wheel(dom::wheel(&native, origin)));
102                }
103            },
104        }
105    }
106}
107
108/// Where the drag is, told to the editor as a motion — so what it drops
109/// lands under it rather than wherever the pointer was before the browser
110/// took it for the drag (`dom::dragged`).
111fn dragged(shell: &Shell, native: &web_sys::DragEvent) {
112    if let Some(origin) = shell.origin() {
113        shell.sampled(Sample::Pointer(dom::dragged(native, origin)));
114    }
115}
116
117fn pointed_by(shell: &Shell, event: &dioxus::core::Event<PointerData>, phase: Phase) {
118    if let Some(native) = event.try_as_web_event() {
119        pointed(shell, &native, phase);
120    }
121}
122
123fn pointed(shell: &Shell, native: &PointerEvent, phase: Phase) {
124    let Some(origin) = shell.origin() else {
125        return;
126    };
127    shell.sampled(Sample::Pointer(dom::pointer(native, phase, origin)));
128}
129
130/// The page's keyboard and clipboard, read for the drawing at the document
131/// rather than on any element this app renders: with nothing focused, a key is
132/// aimed at `<body>`, above all of them. The listeners are the page's, and
133/// end with the tab.
134pub fn listens(shell: &Shell) {
135    let Some(document) = crate::shell::window().and_then(|window| window.document()) else {
136        return;
137    };
138    let on = |kind: &str, heard: Box<dyn FnMut(web_sys::Event)>| {
139        let heard = Closure::wrap(heard);
140        if document
141            .add_event_listener_with_callback(kind, heard.as_ref().unchecked_ref())
142            .is_ok()
143        {
144            heard.forget();
145        }
146    };
147    for (kind, edge) in [("keydown", Edge::Down), ("keyup", Edge::Up)] {
148        let shell = shell.clone();
149        on(
150            kind,
151            Box::new(move |event| {
152                if let Some(native) = event.dyn_ref::<KeyboardEvent>() {
153                    keyed_by(&shell, native, edge);
154                }
155            }),
156        );
157    }
158    let pasting = shell.clone();
159    on("paste", Box::new(move |event| pasted_on(&pasting, &event)));
160    for (kind, id) in [("copy", CommandId::Copy), ("cut", CommandId::Cut)] {
161        let shell = shell.clone();
162        on(kind, Box::new(move |event| clipped_on(&shell, &event, id)));
163    }
164}
165
166/// A keystroke anywhere on the page. A field being typed into keeps its own
167/// keys, and so does a control that took one for itself.
168fn keyed_by(shell: &Shell, native: &KeyboardEvent, edge: Edge) {
169    // Whoever the key belongs to, it says the user is at the keyboard — so a
170    // paste that follows is one they asked for, not one the middle button
171    // provoked.
172    if edge == Edge::Down {
173        shell.asked();
174    }
175    if edge == Edge::Down && (kept_by_a_control(native) || typed_into(native)) {
176        return;
177    }
178    keyed(shell, native, edge);
179}
180
181/// Whether a control took the key. A menu takes Escape — and says so even
182/// while it is closed, when there is nothing for it to close; the key is then
183/// the drawing's.
184fn kept_by_a_control(native: &KeyboardEvent) -> bool {
185    native.default_prevented() && (native.key() != "Escape" || opened_around(native))
186}
187
188/// Whether the event came from inside something open. The attribute still
189/// says what it was before this key: the page re-renders after the event.
190fn opened_around(native: &web_sys::Event) -> bool {
191    native
192        .target()
193        .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
194        .and_then(|element| element.closest("[data-state=open]").ok().flatten())
195        .is_some()
196}
197
198/// A keystroke the canvas reads, or a chord this call has a command for.
199/// Everything else is left to the browser.
200fn keyed(shell: &Shell, native: &KeyboardEvent, edge: Edge) {
201    // Escape closes the glass before anything else reads it, so a tool armed
202    // before the panel opened is not disarmed by the key that shut it.
203    if edge == Edge::Down && native.key() == "Escape" && shell.escaped() == Escaped::Claimed {
204        native.prevent_default();
205        return;
206    }
207    if let Some(sample) = dom::key(native, edge) {
208        // Space would scroll the page, or press the focused button, and
209        // Backspace would leave the page.
210        native.prevent_default();
211        shell.sampled(Sample::Key(sample));
212        return;
213    }
214    if edge != Edge::Down {
215        return;
216    }
217    let Some(id) = chord_of(native).and_then(|chord| shell.chrome().peek().commands.bound(chord))
218    else {
219        return;
220    };
221    native.prevent_default();
222    shell.say(Event::Command(id));
223}
224
225/// A copy or a cut anywhere but a field takes the selection, when there is
226/// one to take — and is otherwise left to the browser.
227fn clipped_on(shell: &Shell, native: &web_sys::Event, id: CommandId) {
228    let offered = shell
229        .chrome()
230        .peek()
231        .commands
232        .face(id)
233        .is_some_and(|face| !face.availability.disabled());
234    if typed_into(native) || !offered {
235        return;
236    }
237    native.prevent_default();
238    shell.say(Event::Command(id));
239}
240
241/// A paste anywhere but a field is an object paste — as the egui shell takes
242/// one.
243fn pasted_on(shell: &Shell, native: &web_sys::Event) {
244    if typed_into(native) {
245        return;
246    }
247    let pasted = native
248        .dyn_ref::<web_sys::ClipboardEvent>()
249        .and_then(web_sys::ClipboardEvent::clipboard_data)
250        .and_then(|data| data.get_data("text/plain").ok())
251        .filter(|text| !text.is_empty());
252    let Some(text) = pasted else {
253        return;
254    };
255    if !shell.takes_a_paste() {
256        // The middle button provoked it; the pan it belongs to is the whole
257        // gesture. Refused rather than passed on, so the diagram is not
258        // written to by a camera move.
259        native.prevent_default();
260        return;
261    }
262    native.prevent_default();
263    shell.say(Event::Action(Action::Paste(text)));
264}
265
266fn typed_into(native: &web_sys::Event) -> bool {
267    native
268        .target()
269        .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
270        .is_some_and(|target| types_into(&target))
271}