Skip to main content

vertigo_forms/
drop_image_file.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD as BASE_64};
2use std::rc::Rc;
3use vertigo::{
4    AttrGroup, Computed, Css, DropFileEvent, DropFileItem, Value, bind, component, computed_tuple,
5    css, dom,
6};
7
8/// Box that allows to accept image files on it, connected to `Value<Option<DropFileItem>>`.
9#[component]
10pub fn DropImageFile(
11    original_link: Computed<Option<Rc<String>>>,
12    item: Value<Option<DropFileItem>>,
13    params: DropImageFileParams,
14    /// Any additional attributes for the dropzone
15    zone: AttrGroup,
16) {
17    let base64_data = item.to_computed().map(|item| match item {
18        Some(item) => image_as_uri(&item),
19        None => "".to_string(),
20    });
21
22    let view_deps = computed_tuple!(
23        a => original_link,
24        b => item,
25        c => base64_data
26    );
27    let item_clone = item.clone();
28    let params = params.clone();
29    let callback = params.callback.clone();
30
31    // Shared handler factory for the file-input-select path (cloned per render branch)
32    let item_for_select = item.clone();
33    let callback_for_select = params.callback.clone();
34    let make_on_change_file = Rc::new(move || {
35        let item = item_for_select.clone();
36        let callback = callback_for_select.clone();
37        move |event: DropFileEvent| {
38            for file in event.items {
39                if let Some(cb) = callback.as_deref() {
40                    cb(Some(file));
41                } else {
42                    item.set(Some(file));
43                }
44            }
45        }
46    });
47
48    let image_view = view_deps.render_value(move |(original, item, base64_date)| {
49        let hidden_input_css = css! {"display: none;"};
50        let btn_css = css! {"
51            text-align: center;
52            display: inline-block;
53            cursor: pointer;
54            padding: 4px 12px;
55            border: 1px solid currentColor;
56            border-radius: 4px;
57            font-size: 0.85em;
58            margin-top: 6px;
59            margin-bottom: 6px;
60        "};
61        let select_label = params.select_label.clone();
62        let accept = params.accept.clone();
63        let flex_column = css! {"display: flex; flex-direction: column; align-items: center;"};
64
65        match item {
66            Some(item) => {
67                let message = format_line(&item);
68                let restore = bind!(item_clone, callback, |_| {
69                    if let Some(callback) = &callback {
70                        callback(None);
71                    } else {
72                        item_clone.set(None);
73                    }
74                });
75                let restore_text = if original.is_some() {
76                    &params.revert_label
77                } else {
78                    &params.cancel_label
79                };
80                let select_button = if select_label.is_empty() {
81                    None
82                } else {
83                    Some(dom! {
84                        <label css={&btn_css}>
85                            <input
86                                css={hidden_input_css}
87                                type="file"
88                                accept={accept}
89                                on_change_file={make_on_change_file()}
90                            />
91                            {select_label}
92                        </label>
93                    })
94                };
95                dom! {
96                    <div css={flex_column}>
97                        <button css={btn_css}on_click={restore}>{restore_text}</button>
98                        {..select_button}
99                        <img css={&params.img_css} src={base64_date} />
100                        { message }
101                    </div>
102                }
103            }
104            None => match original {
105                Some(original) => {
106                    if select_label.is_empty() {
107                        dom! { <div><img css={&params.img_css} src={original} /></div> }
108                    } else {
109                        dom! {
110                            <div css={flex_column}>
111                                <img css={&params.img_css} src={original} />
112                                <label css={btn_css}>
113                                    <input
114                                        css={hidden_input_css}
115                                        type="file"
116                                        accept={accept}
117                                        on_change_file={make_on_change_file()}
118                                    />
119                                    {select_label}
120                                </label>
121                            </div>
122                        }
123                    }
124                }
125                None => {
126                    if select_label.is_empty() {
127                        dom! { <div>{&params.no_image_text}</div> }
128                    } else {
129                        dom! {
130                            <div css={flex_column}>
131                                {&params.no_image_text}
132                                <label css={btn_css}>
133                                    <input
134                                        css={hidden_input_css}
135                                        type="file"
136                                        accept={accept}
137                                        on_change_file={make_on_change_file()}
138                                    />
139                                    {select_label}
140                                </label>
141                            </div>
142                        }
143                    }
144                }
145            },
146        }
147    });
148
149    let item = item.clone();
150    let callback = params.callback.clone();
151    let on_dropfile = move |event: DropFileEvent| {
152        for file in event.items.into_iter() {
153            if let Some(callback) = callback.as_deref() {
154                callback(Some(file));
155            } else {
156                item.set(Some(file));
157            }
158        }
159    };
160
161    let dropzone_css = &params.dropzone_css + &params.dropzone_add_css;
162
163    dom! {
164        <div css={dropzone_css} on_dropfile={on_dropfile} {..zone}>
165            { image_view }
166        </div>
167    }
168}
169
170#[derive(Clone)]
171pub struct DropImageFileParams {
172    /// Custom callback when new image dropped, leave empty to automatically set/unset `item`
173    pub callback: Option<Rc<dyn Fn(Option<DropFileItem>)>>,
174    pub revert_label: String,
175    pub cancel_label: String,
176    pub no_image_text: String,
177    pub dropzone_css: Css,
178    pub dropzone_add_css: Css,
179    pub img_css: Css,
180    /// Label for the "select file" button. Set to empty string to hide the button.
181    pub select_label: String,
182    /// Value for the `accept` attribute on the hidden file input (e.g. `"image/*"`).
183    pub accept: String,
184}
185
186impl Default for DropImageFileParams {
187    fn default() -> Self {
188        Self {
189            callback: None,
190            revert_label: "Revert".to_string(),
191            cancel_label: "Cancel".to_string(),
192            no_image_text: "No image".to_string(),
193            dropzone_css: css! {"
194                width: 400px;
195                height: 400px;
196
197                display: flex;
198                align-items: center;
199                justify-content: center;
200
201                padding: 10px;
202            "},
203            dropzone_add_css: css! {""},
204            img_css: css! {"
205                max-width: 100%;
206                max-height: 320px;
207                object-fit: contain;
208            "},
209            select_label: "Select file...".to_string(),
210            accept: "image/*".to_string(),
211        }
212    }
213}
214
215fn format_line(item: &DropFileItem) -> String {
216    let file_name = &item.name;
217    let size = item.data.len();
218    format!("{file_name} ({size})")
219}
220
221pub fn name_to_mime(name: &str) -> &'static str {
222    use std::{ffi::OsStr, path::Path};
223
224    let extension = Path::new(name)
225        .extension()
226        .and_then(OsStr::to_str)
227        .unwrap_or_default();
228
229    match extension {
230        "jpg" | "jpeg" | "jpe" => "image/jpeg",
231        "png" => "image/png",
232        "svg" => "image/svg+xml",
233        "gif" => "image/gif",
234        "bmp" => "image/bmp",
235        "ico" => "image/ico",
236        _ => "application/octet-stream",
237    }
238}
239
240pub fn image_as_uri(item: &DropFileItem) -> String {
241    let mime = name_to_mime(&item.name);
242    let data = BASE_64.encode(&*item.data);
243    format!("data:{mime};base64,{data}")
244}