1use 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
28const 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
36const 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
41const 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
45const 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 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 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 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
162fn 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#[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 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#[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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
312enum RowAct {
313 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 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}