Skip to main content

blockworx_web/
diagrams.rs

1//! The Diagrams section: everything about documents, in one place
2//! (`docs/cad-ui-spec.md` §4.1, §8).
3//!
4//! This diagram first — its name and state, rename and delete, and every
5//! format it leaves in — then the way to a new one or an imported one, and
6//! every diagram this browser holds, newest first. Opening one replaces the
7//! diagram in this tab, and the section says so, since with no tabs nothing
8//! else would.
9
10use blockworx_kernel::Liveness;
11use blockworx_store::doc::Renaming;
12use blockworx_store::storage::DocumentRef;
13use blockworx_tools::commands::{CommandId, Effect, ExportFormat};
14use dioxus::prelude::*;
15use dioxus::web::WebEventExt as _;
16use dioxus_primitives::dropdown_menu::{
17    DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
18};
19
20use crate::control::{Face, Press, Pressable, Pressing};
21use crate::exchange::Zone;
22use crate::icons;
23use crate::library::Listed;
24use crate::sheet::{CURRENT, FIELD, MUTED, ROW};
25use crate::shell::Shell;
26use crate::top_bar::MENU_ROW;
27
28/// A square action on this diagram's card.
29const ACT: &str = "grid size-9 flex-none place-items-center rounded-[10px] text-zinc-600 \
30     transition live:hover:bg-zinc-950/6 dead:opacity-30 dark:text-zinc-300 \
31     dark:live:hover:bg-white/10 [&_svg]:size-[18px] [&_svg]:stroke-[1.7]";
32const ACT_DANGER: &str = "grid size-9 flex-none place-items-center rounded-[10px] text-red-600 \
33     transition live:hover:bg-red-500/10 dead:opacity-30 dark:text-red-400 \
34     [&_svg]:size-[18px] [&_svg]:stroke-[1.7]";
35
36/// One format this diagram leaves in.
37const FORMAT: &str = "flex h-[34px] flex-1 items-center justify-center rounded-[10px] \
38     bg-zinc-950/5 text-[13px] transition live:hover:bg-zinc-950/10 dead:opacity-40 \
39     dark:bg-white/5 dark:live:hover:bg-white/10";
40
41/// New and Import.
42const ACTION: &str = "flex h-10 items-center gap-2.5 rounded-[10px] px-3 text-sm transition \
43     hover:bg-zinc-950/5 dark:hover:bg-white/5 [&_svg]:size-[18px] [&_svg]:stroke-[1.7]";
44
45/// A row's own menu, and the handle that opens it — always drawn, since
46/// nothing may be reachable by hovering alone.
47const MORE: &str = "grid size-8 flex-none place-items-center rounded-lg text-zinc-500 \
48     outline-none transition hover:bg-zinc-950/6 hover:text-zinc-800 \
49     data-[state=open]:bg-zinc-950/6 dark:text-zinc-400 dark:hover:bg-white/10 \
50     dark:hover:text-zinc-100 dark:data-[state=open]:bg-white/10 [&_svg]:size-[18px]";
51const ROW_MENU: &str = "absolute right-0 top-full z-50 mt-1 min-w-48 rounded-2xl bg-white p-1.5 \
52     text-zinc-900 shadow-lg ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:text-zinc-100 \
53     dark:ring-white/10 data-[state=closed]:hidden";
54
55#[component]
56pub fn Diagrams(shell: Shell) -> Element {
57    let held = shell.held();
58    let mut search = use_signal(String::new);
59    let typed = search().trim().to_lowercase();
60    // Which row is this tab's is read off the chrome, so opening another
61    // diagram re-marks the list even when the list itself is unchanged. A
62    // session with a container is one whose name can be typed over.
63    let here = {
64        let chrome = shell.chrome();
65        let bar = &chrome.read().top_bar;
66        (bar.renaming == Renaming::Offered).then(|| bar.name.clone())
67    };
68    let listed: Vec<Listed> = held
69        .read()
70        .iter()
71        .filter(|entry| typed.is_empty() || entry.document().to_lowercase().contains(&typed))
72        .cloned()
73        .collect();
74    let raise = use_hook(|| CopyValue::new(shell.clone()));
75    let mut over = use_signal(|| false);
76    rsx! {
77        // The section is a drop zone: a diagram dropped here is filed in
78        // this browser, where one dropped on the canvas is embedded in the
79        // drawing instead. `dragover` is refused so the browser does not
80        // open the file in place of the page, and `dragleave` has to be
81        // heard too, a drag being the one gesture that can end off the
82        // element it started over.
83        div {
84            class: "flex flex-col gap-2.5 px-1 rounded-lg transition-colors \
85                    data-[over=true]:bg-sky-500/10 data-[over=true]:outline \
86                    data-[over=true]:outline-2 data-[over=true]:outline-dashed \
87                    data-[over=true]:outline-sky-500/50",
88            "data-over": "{over()}",
89            "data-drop": "library",
90            ondragover: move |event| {
91                if let Some(native) = event.try_as_web_event() {
92                    native.prevent_default();
93                    over.set(true);
94                }
95            },
96            // `dragleave` bubbles out of every child the cursor crosses, so a
97            // drag over the rows would flicker the zone off and on. The one
98            // that means it names something outside the section, or nothing.
99            ondragleave: move |event| {
100                if let Some(native) = event.try_as_web_event()
101                    && left_for_good(&native)
102                {
103                    over.set(false);
104                }
105            },
106            ondrop: move |event| {
107                over.set(false);
108                let Some(native) = event.try_as_web_event() else {
109                    return;
110                };
111                native.prevent_default();
112                if let Some(file) = crate::exchange::carried_by(&native) {
113                    crate::exchange::drops(&raise.read(), Zone::Library, &file);
114                }
115            },
116            ThisDiagram { shell: shell.clone() }
117            input {
118                class: FIELD,
119                value: "{search}",
120                placeholder: "Find a diagram",
121                "aria-label": "Find a diagram",
122                "data-search": "diagrams",
123                oninput: move |event| search.set(event.value()),
124            }
125            div { class: "flex flex-col",
126                Pressable {
127                    class: ACTION,
128                    "data-cmd": "new-document",
129                    onpress: move |()| raise.read().raises(Effect::NewDocument.into()),
130                    Face { icon: icons::ADD }
131                    span { "New diagram" }
132                }
133                Pressable {
134                    class: ACTION,
135                    "data-cmd": "import-archive",
136                    onpress: move |()| crate::library::picks_an_archive(&raise.read()),
137                    Face { icon: icons::IMPORT }
138                    span { "Import .bwx.zip\u{2026}" }
139                }
140            }
141            span { class: "mx-1.5 h-px bg-zinc-950/10 dark:bg-white/10" }
142            if listed.is_empty() {
143                p { class: "{MUTED} px-3 py-2",
144                    if typed.is_empty() { "No diagrams in this browser yet" } else { "Nothing matches" }
145                }
146            }
147            div { class: "flex flex-col gap-0.5",
148                for entry in listed {
149                    DiagramRow {
150                        key: "{entry.name}",
151                        shell: shell.clone(),
152                        current: here.as_deref() == Some(entry.document().as_str()),
153                        entry,
154                    }
155                }
156            }
157            p { class: "{MUTED} px-3 pt-1", "Opening a diagram replaces this one in this tab." }
158        }
159    }
160}
161
162/// Whether a `dragleave` took the cursor out of the element it fired on,
163/// rather than into one of its own children.
164fn left_for_good(native: &web_sys::DragEvent) -> bool {
165    use wasm_bindgen::JsCast as _;
166    let Some(zone) = native
167        .current_target()
168        .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
169    else {
170        return true;
171    };
172    match native
173        .related_target()
174        .and_then(|target| target.dyn_into::<web_sys::Node>().ok())
175    {
176        Some(into) => !zone.contains(Some(&into)),
177        None => true,
178    }
179}
180
181/// The diagram in this tab: its name and state, the two things done to it,
182/// and every format it leaves in.
183#[component]
184fn ThisDiagram(shell: Shell) -> Element {
185    let chrome = shell.chrome();
186    let bar = chrome.read().top_bar.clone();
187    let (tint, _) = crate::top_bar::reading(bar.liveness);
188    // Every door but the formats acts on the container behind the session,
189    // and whether there is one is what the breadcrumb's rename already asks.
190    let pressing = match bar.renaming {
191        Renaming::Offered => Pressing::Live,
192        Renaming::Withheld => Pressing::Dead,
193    };
194    let state = match bar.liveness {
195        Liveness::Recorded => "saved",
196        Liveness::ReadOnly(_) => "read-only",
197        Liveness::Scratch => "not saved",
198    };
199    let raise = use_hook(|| CopyValue::new(shell.clone()));
200    rsx! {
201        div {
202            class: "flex flex-col gap-2.5 rounded-2xl bg-sky-500/12 p-3 dark:bg-sky-400/15",
203            "data-this": "true",
204            div { class: "flex items-center gap-2.5",
205                span { class: "size-2 flex-none rounded-full {tint}" }
206                span { class: "min-w-0 flex-1",
207                    span { class: "block truncate text-sm font-semibold", "{bar.name}" }
208                    span { class: "{MUTED} block truncate",
209                        "This diagram \u{00b7} {state} \u{00b7} rev {bar.lens.head.get()}"
210                    }
211                }
212                Pressable {
213                    class: ACT,
214                    title: "Rename",
215                    "aria-label": "Rename",
216                    "data-cmd": "rename-document",
217                    pressing,
218                    onpress: move |()| raise.read().asks_to_rename(),
219                    Face { icon: icons::FILE }
220                }
221                Pressable {
222                    class: ACT_DANGER,
223                    title: "Delete this diagram",
224                    "aria-label": "Delete this diagram",
225                    "data-cmd": "delete-document",
226                    pressing,
227                    onpress: move |()| raise.read().deletes(),
228                    Face { icon: icons::TRASH }
229                }
230            }
231            div { class: "flex flex-col gap-1.5",
232                span { class: MUTED, "Export as" }
233                div { class: "flex gap-1.5",
234                    for (format , says) in [
235                        (ExportFormat::Svg, "SVG"),
236                        (ExportFormat::Png, "PNG"),
237                        (ExportFormat::Pdf, "PDF"),
238                    ]
239                    {
240                        Press {
241                            key: "{says}",
242                            shell: shell.clone(),
243                            id: CommandId::Export(format),
244                            says: format!("Export {says}"),
245                            class: FORMAT,
246                            "{says}"
247                        }
248                    }
249                    Pressable {
250                        class: FORMAT,
251                        title: "Export .bwx.zip",
252                        "data-cmd": "export-archive",
253                        pressing,
254                        onpress: move |()| raise.read().exports_archive(),
255                        ".bwx.zip"
256                    }
257                }
258            }
259        }
260    }
261}
262
263/// One diagram the browser holds: a press opens it, and its menu offers what
264/// can be done to it where it stands.
265#[component]
266fn DiagramRow(shell: Shell, entry: Listed, current: bool) -> Element {
267    let raise = use_hook(|| CopyValue::new(shell.clone()));
268    let name = entry.name.clone();
269    let said = if current {
270        Some("Open in this tab".to_owned())
271    } else {
272        entry.edited(blockworx_store::history::now())
273    };
274    rsx! {
275        div { class: "flex items-center gap-1",
276            Pressable {
277                class: if current { CURRENT } else { ROW },
278                "data-cmd": "open:{name}",
279                "aria-current": if current { "true" } else { "false" },
280                onpress: {
281                    let name = name.clone();
282                    move |()| {
283                        if !current {
284                            raise
285                                .read()
286                                .raises(Effect::OpenRecent(DocumentRef::new(name.clone())).into());
287                        }
288                    }
289                },
290                span {
291                    class: if current {
292                        "flex-none text-sky-600 dark:text-sky-400 [&_svg]:size-[18px] [&_svg]:stroke-[1.7]"
293                    } else {
294                        "flex-none text-zinc-500 dark:text-zinc-400 [&_svg]:size-[18px] [&_svg]:stroke-[1.7]"
295                    },
296                    Face { icon: icons::FILE }
297                }
298                span { class: "min-w-0 flex-1",
299                    span { class: "block truncate text-sm font-medium", "{entry.document()}" }
300                    if let Some(said) = said {
301                        span { class: "{MUTED} block truncate", "{said}" }
302                    }
303                }
304            }
305            RowMenu { shell, name, current }
306        }
307    }
308}
309
310/// What a row's menu can do.
311#[derive(Clone, Copy, PartialEq, Eq, Debug)]
312enum RowAct {
313    /// Only the diagram in this tab: the rename box is the breadcrumb's, and
314    /// a container nothing has open is not renamed from here.
315    Rename,
316    Export,
317    Delete,
318}
319
320impl RowAct {
321    fn says(self) -> &'static str {
322        match self {
323            RowAct::Rename => "Rename\u{2026}",
324            RowAct::Export => "Export .bwx.zip",
325            RowAct::Delete => "Delete",
326        }
327    }
328
329    fn named(self) -> &'static str {
330        match self {
331            RowAct::Rename => "row-rename",
332            RowAct::Export => "row-export",
333            RowAct::Delete => "row-delete",
334        }
335    }
336
337    /// Delete is inked as what it is; the rest are ordinary rows.
338    fn look(self) -> String {
339        match self {
340            RowAct::Delete => format!("{MENU_ROW} text-red-600 dark:text-red-400"),
341            RowAct::Rename | RowAct::Export => MENU_ROW.to_owned(),
342        }
343    }
344
345    fn icon(self) -> icons::Icon {
346        match self {
347            RowAct::Rename => icons::FILE,
348            RowAct::Export => icons::EXPORT,
349            RowAct::Delete => icons::TRASH,
350        }
351    }
352
353    fn raised(self, shell: &Shell, name: &str) {
354        match self {
355            RowAct::Rename => shell.asks_to_rename(),
356            RowAct::Export => shell.exports_named(name),
357            RowAct::Delete => shell.deletes_named(name),
358        }
359    }
360}
361
362#[component]
363fn RowMenu(shell: Shell, name: String, current: bool) -> Element {
364    let acts: &[RowAct] = if current {
365        &[RowAct::Rename, RowAct::Export, RowAct::Delete]
366    } else {
367        &[RowAct::Export, RowAct::Delete]
368    };
369    let raise = use_hook(|| CopyValue::new(shell));
370    rsx! {
371        DropdownMenu { class: "relative flex-none",
372            DropdownMenuTrigger { class: MORE, title: "More for {name}", "data-more": "{name}",
373                Face { icon: icons::MORE }
374            }
375            DropdownMenuContent { class: ROW_MENU,
376                for (place , act) in acts.iter().copied().enumerate() {
377                    DropdownMenuItem::<RowAct> {
378                        key: "{act:?}",
379                        class: act.look(),
380                        "data-cmd": "{act.named()}",
381                        value: act,
382                        index: place,
383                        on_select: {
384                            let name = name.clone();
385                            move |act: RowAct| act.raised(&raise.read(), &name)
386                        },
387                        Face { icon: act.icon() }
388                        "{act.says()}"
389                    }
390                }
391            }
392        }
393    }
394}