Add the File > Export dialog and PNG/JPG downloading (#629)

* Add export dialog

* Code review changes

* More code review feedback

* Fix compilation on stable Rust

* Fixes to problems

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube
2022-05-09 07:35:22 +01:00
committed by Keavon Chambers
co-authored by Keavon Chambers
parent e3e506ecfb
commit 060182fd31
19 changed files with 445 additions and 59 deletions
-1
View File
@@ -61,7 +61,6 @@ pub const SCALE_EFFECT: f64 = 0.5;
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
pub const FILE_EXPORT_SUFFIX: &str = ".svg";
// Colors
pub const COLOR_ACCENT: Color = Color::from_unsafe(0x00 as f32 / 255., 0xA8 as f32 / 255., 0xFF as f32 / 255.);
+6 -1
View File
@@ -1,12 +1,16 @@
use crate::message_prelude::*;
use serde::{Deserialize, Serialize};
use super::NewDocumentDialogUpdate;
use super::{ExportDialogUpdate, NewDocumentDialogUpdate};
#[remain::sorted]
#[impl_message(Message, Dialog)]
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum DialogMessage {
#[remain::unsorted]
#[child]
ExportDialog(ExportDialogUpdate),
#[remain::unsorted]
#[child]
NewDocumentDialog(NewDocumentDialogUpdate),
@@ -23,5 +27,6 @@ pub enum DialogMessage {
RequestComingSoonDialog {
issue: Option<i32>,
},
RequestExportDialog,
RequestNewDocumentDialog,
}
+35 -1
View File
@@ -7,6 +7,7 @@ use super::*;
#[derive(Debug, Default, Clone)]
pub struct DialogMessageHandler {
export_dialog: Export,
new_document_dialog: NewDocument,
}
@@ -15,6 +16,8 @@ impl MessageHandler<DialogMessage, (&BuildMetadata, &PortfolioMessageHandler)> f
fn process_action(&mut self, message: DialogMessage, (build_metadata, portfolio): (&BuildMetadata, &PortfolioMessageHandler), responses: &mut VecDeque<Message>) {
#[remain::sorted]
match message {
#[remain::unsorted]
DialogMessage::ExportDialog(message) => self.export_dialog.process_action(message, (), responses),
#[remain::unsorted]
DialogMessage::NewDocumentDialog(message) => self.new_document_dialog.process_action(message, (), responses),
@@ -44,6 +47,37 @@ impl MessageHandler<DialogMessage, (&BuildMetadata, &PortfolioMessageHandler)> f
coming_soon.register_properties(responses, LayoutTarget::DialogDetails);
responses.push_back(FrontendMessage::DisplayDialog { icon: "Warning".to_string() }.into());
}
DialogMessage::RequestExportDialog => {
let artboard_handler = &portfolio.active_document().artboard_message_handler;
let mut index = 0;
let artboards = artboard_handler
.artboard_ids
.iter()
.rev()
.filter_map(|&artboard| artboard_handler.artboards_graphene_document.layer(&[artboard]).ok().map(|layer| (artboard, layer)))
.map(|(artboard, layer)| {
(
artboard,
format!(
"Artboard: {}",
layer.name.clone().unwrap_or_else(|| {
index += 1;
format!("Untitled {index}")
})
),
)
})
.collect();
self.export_dialog = Export {
file_name: portfolio.active_document().name.clone(),
scale_factor: 1.,
artboards,
..Default::default()
};
self.export_dialog.register_properties(responses, LayoutTarget::DialogDetails);
responses.push_back(FrontendMessage::DisplayDialog { icon: "File".to_string() }.into());
}
DialogMessage::RequestNewDocumentDialog => {
self.new_document_dialog = NewDocument {
name: portfolio.generate_new_document_name(),
@@ -56,5 +90,5 @@ impl MessageHandler<DialogMessage, (&BuildMetadata, &PortfolioMessageHandler)> f
}
}
advertise_actions!(DialogMessageDiscriminant;RequestNewDocumentDialog,CloseAllDocumentsWithConfirmation);
advertise_actions!(DialogMessageDiscriminant;RequestNewDocumentDialog,RequestExportDialog,CloseAllDocumentsWithConfirmation);
}
+186
View File
@@ -0,0 +1,186 @@
use std::collections::HashMap;
use crate::frontend::utility_types::{ExportBounds, FileType};
use crate::layout::layout_message::LayoutTarget;
use crate::layout::widgets::*;
use crate::message_prelude::*;
use serde::{Deserialize, Serialize};
/// A dialog to allow users to customise their file export.
#[derive(Debug, Clone, Default)]
pub struct Export {
pub file_name: String,
pub file_type: FileType,
pub scale_factor: f64,
pub bounds: ExportBounds,
pub artboards: HashMap<LayerId, String>,
}
impl PropertyHolder for Export {
fn properties(&self) -> WidgetLayout {
let file_name = vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "File Name".into(),
table_align: true,
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextInput(TextInput {
value: self.file_name.clone(),
on_update: WidgetCallback::new(|text_input: &TextInput| ExportDialogUpdate::FileName(text_input.value.clone()).into()),
})),
];
let entries = [(FileType::Svg, "SVG"), (FileType::Png, "PNG"), (FileType::Jpg, "JPG")]
.into_iter()
.map(|(val, name)| RadioEntryData {
label: name.into(),
on_update: WidgetCallback::new(move |_| ExportDialogUpdate::FileType(val).into()),
..RadioEntryData::default()
})
.collect();
let export_type = vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "File Type".into(),
table_align: true,
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::RadioInput(RadioInput {
selected_index: self.file_type as u32,
entries,
})),
];
let artboards = self.artboards.iter().map(|(&val, name)| (ExportBounds::Artboard(val), name.to_string()));
let mut export_area_options = vec![(ExportBounds::AllArtwork, "All Artwork".to_string())];
export_area_options.extend(artboards);
let index = export_area_options.iter().position(|(val, _)| val == &self.bounds).unwrap();
let menu_entries = vec![export_area_options
.into_iter()
.map(|(val, name)| DropdownEntryData {
label: name,
on_update: WidgetCallback::new(move |_| ExportDialogUpdate::ExportBounds(val).into()),
..Default::default()
})
.collect()];
let export_area = vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Bounds".into(),
table_align: true,
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::DropdownInput(DropdownInput {
selected_index: index as u32,
menu_entries,
..Default::default()
})),
];
let resolution = vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Scale Factor".into(),
table_align: true,
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: self.scale_factor,
label: "".into(),
unit: " ".into(),
disabled: self.file_type == FileType::Svg,
min: Some(0.),
on_update: WidgetCallback::new(|number_input: &NumberInput| ExportDialogUpdate::ScaleFactor(number_input.value).into()),
..NumberInput::default()
})),
];
let button_widgets = vec![
WidgetHolder::new(Widget::TextButton(TextButton {
label: "OK".to_string(),
min_width: 96,
emphasized: true,
on_update: WidgetCallback::new(|_| {
DialogMessage::CloseDialogAndThen {
followup: Box::new(ExportDialogUpdate::Submit.into()),
}
.into()
}),
..Default::default()
})),
WidgetHolder::new(Widget::TextButton(TextButton {
label: "Cancel".to_string(),
min_width: 96,
on_update: WidgetCallback::new(|_| FrontendMessage::DisplayDialogDismiss.into()),
..Default::default()
})),
];
WidgetLayout::new(vec![
LayoutRow::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Export".to_string(),
bold: true,
..Default::default()
}))],
},
LayoutRow::Row { widgets: file_name },
LayoutRow::Row { widgets: export_type },
LayoutRow::Row { widgets: resolution },
LayoutRow::Row { widgets: export_area },
LayoutRow::Row { widgets: button_widgets },
])
}
}
#[impl_message(Message, DialogMessage, ExportDialog)]
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum ExportDialogUpdate {
FileName(String),
FileType(FileType),
ScaleFactor(f64),
ExportBounds(ExportBounds),
Submit,
}
impl MessageHandler<ExportDialogUpdate, ()> for Export {
fn process_action(&mut self, action: ExportDialogUpdate, _data: (), responses: &mut VecDeque<Message>) {
match action {
ExportDialogUpdate::FileName(name) => self.file_name = name,
ExportDialogUpdate::FileType(export_type) => self.file_type = export_type,
ExportDialogUpdate::ScaleFactor(x) => self.scale_factor = x,
ExportDialogUpdate::ExportBounds(export_area) => self.bounds = export_area,
ExportDialogUpdate::Submit => responses.push_front(
DocumentMessage::ExportDocument {
file_name: self.file_name.clone(),
file_type: self.file_type,
scale_factor: self.scale_factor,
bounds: self.bounds,
}
.into(),
),
}
self.register_properties(responses, LayoutTarget::DialogDetails);
}
advertise_actions! {ExportDialogUpdate;}
}
+2
View File
@@ -3,6 +3,7 @@ mod close_all_documents_dialog;
mod close_document_dialog;
mod coming_soon_dialog;
mod error_dialog;
mod export_dialog;
mod new_document_dialog;
pub use about_dialog::AboutGraphite;
@@ -10,4 +11,5 @@ pub use close_all_documents_dialog::CloseAllDocuments;
pub use close_document_dialog::CloseDocument;
pub use coming_soon_dialog::ComingSoon;
pub use error_dialog::Error;
pub use export_dialog::{Export, ExportDialogUpdate, ExportDialogUpdateDiscriminant};
pub use new_document_dialog::{NewDocument, NewDocumentDialogUpdate, NewDocumentDialogUpdateDiscriminant};
+7 -1
View File
@@ -1,5 +1,6 @@
use super::layer_panel::LayerMetadata;
use super::utility_types::{AlignAggregate, AlignAxis, FlipAxis};
use crate::frontend::utility_types::{ExportBounds, FileType};
use crate::message_prelude::*;
use graphene::boolean_ops::BooleanOperation as BooleanOperationType;
@@ -59,7 +60,12 @@ pub enum DocumentMessage {
DocumentHistoryForward,
DocumentStructureChanged,
DuplicateSelectedLayers,
ExportDocument,
ExportDocument {
file_name: String,
file_type: FileType,
scale_factor: f64,
bounds: ExportBounds,
},
FlipSelectedLayers {
flip_axis: FlipAxis,
},
+48 -30
View File
@@ -5,10 +5,8 @@ use super::utility_types::TargetDocument;
use super::utility_types::{AlignAggregate, AlignAxis, DocumentSave, FlipAxis};
use super::{vectorize_layer_metadata, PropertiesPanelMessageHandler};
use super::{ArtboardMessageHandler, MovementMessageHandler, OverlaysMessageHandler, TransformLayerMessageHandler};
use crate::consts::{
ASYMPTOTIC_EFFECT, DEFAULT_DOCUMENT_NAME, FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX, GRAPHITE_DOCUMENT_VERSION, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR,
};
use crate::frontend::utility_types::FrontendImageData;
use crate::consts::{ASYMPTOTIC_EFFECT, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX, GRAPHITE_DOCUMENT_VERSION, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR};
use crate::frontend::utility_types::{FileType, FrontendImageData};
use crate::input::InputPreprocessorMessageHandler;
use crate::layout::widgets::{
IconButton, LayoutRow, NumberInput, NumberInputIncrementBehavior, OptionalInput, PopoverButton, PropertyHolder, RadioEntryData, RadioInput, Separator, SeparatorDirection, SeparatorType, Widget,
@@ -463,11 +461,7 @@ impl DocumentMessageHandler {
}
pub fn document_bounds(&self) -> Option<[DVec2; 2]> {
if self.artboard_message_handler.is_infinite_canvas() {
self.graphene_document.viewport_bounding_box(&[]).ok().flatten()
} else {
self.artboard_message_handler.artboards_graphene_document.viewport_bounding_box(&[]).ok().flatten()
}
self.graphene_document.viewport_bounding_box(&[]).ok().flatten()
}
/// Calculate the path that new layers should be inserted to.
@@ -858,29 +852,53 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
responses.push_back(DocumentOperation::DuplicateLayer { path: path.to_vec() }.into());
}
}
ExportDocument => {
// TODO(mfish33): Add Dialog to select artboards
let bbox = self.document_bounds().unwrap_or_else(|| [DVec2::ZERO, ipp.viewport_bounds.size()]);
ExportDocument {
file_name,
file_type,
scale_factor,
bounds,
} => {
// Allows the user's transform to be restored
let old_transform = self.graphene_document.root.transform;
// Reset the root's transform (required to avoid any rotation by the user)
self.graphene_document.root.transform = DAffine2::IDENTITY;
self.graphene_document.root.cache_dirty = true;
// Calculates the bounding box of the region to be exported
let bbox = match bounds {
crate::frontend::utility_types::ExportBounds::AllArtwork => self.document_bounds(),
crate::frontend::utility_types::ExportBounds::Artboard(id) => self
.artboard_message_handler
.artboards_graphene_document
.layer(&[id])
.ok()
.and_then(|layer| layer.aabounding_box(&self.graphene_document.font_cache)),
}
.unwrap_or_default();
let size = bbox[1] - bbox[0];
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
true => self.name.clone().replace(FILE_SAVE_SUFFIX, FILE_EXPORT_SUFFIX),
false => self.name.clone() + FILE_EXPORT_SUFFIX,
let file_suffix = &format!(".{file_type:?}").to_lowercase();
let name = match file_name.ends_with(FILE_SAVE_SUFFIX) {
true => file_name.replace(FILE_SAVE_SUFFIX, file_suffix),
false => file_name + file_suffix,
};
responses.push_back(
FrontendMessage::TriggerFileDownload {
document: format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">{}{}</svg>"#,
bbox[0].x,
bbox[0].y,
size.x,
size.y,
"\n",
self.graphene_document.render_root(self.view_mode)
),
name,
}
.into(),
)
let rendered = self.graphene_document.render_root(self.view_mode);
let document = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}" width="{}px" height="{}">{}{}</svg>"#,
bbox[0].x, bbox[0].y, size.x, size.y, size.x, size.y, "\n", rendered
);
self.graphene_document.root.transform = old_transform;
self.graphene_document.root.cache_dirty = true;
if file_type == FileType::Svg {
responses.push_back(FrontendMessage::TriggerFileDownload { document, name }.into());
} else {
let mime = file_type.to_mime().to_string();
let size = (size * scale_factor).into();
responses.push_back(FrontendMessage::TriggerRasterDownload { document, name, mime, size }.into());
}
}
FlipSelectedLayers { flip_axis } => {
self.backup(responses);
+1
View File
@@ -27,6 +27,7 @@ pub enum FrontendMessage {
TriggerFontLoadDefault,
TriggerIndexedDbRemoveDocument { document_id: u64 },
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
TriggerRasterDownload { document: String, name: String, mime: String, size: (f64, f64) },
TriggerTextCommit,
TriggerTextCopy { copy_text: String },
TriggerViewportResize,
+36 -1
View File
@@ -31,6 +31,41 @@ pub enum MouseCursorIcon {
impl Default for MouseCursorIcon {
fn default() -> Self {
Self::Default
MouseCursorIcon::Default
}
}
#[derive(Clone, Copy, Debug, Eq, Deserialize, PartialEq, Serialize)]
pub enum FileType {
Svg,
Png,
Jpg,
}
impl Default for FileType {
fn default() -> Self {
FileType::Svg
}
}
impl FileType {
pub fn to_mime(self) -> &'static str {
match self {
FileType::Svg => "image/svg+xml",
FileType::Png => "image/png",
FileType::Jpg => "image/jpeg",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Deserialize, PartialEq, Serialize)]
pub enum ExportBounds {
AllArtwork,
Artboard(LayerId),
}
impl Default for ExportBounds {
fn default() -> Self {
ExportBounds::AllArtwork
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ impl Default for Mapping {
entry! {action=DocumentMessage::SelectAllLayers, key_down=KeyA, modifiers=[KeyControl]},
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyDelete},
entry! {action=DocumentMessage::DeleteSelectedLayers, key_down=KeyBackspace},
entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]},
entry! {action=DialogMessage::RequestExportDialog, key_down=KeyE, modifiers=[KeyControl]},
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl]},
entry! {action=DocumentMessage::SaveDocument, key_down=KeyS, modifiers=[KeyControl, KeyShift]},
entry! {action=DocumentMessage::DebugPrintDocument, key_down=Key9},
@@ -70,6 +70,12 @@ impl MessageHandler<LayoutMessage, ()> for LayoutMessageHandler {
let callback_message = (color_input.on_update.callback)(color_input);
responses.push_back(callback_message);
}
Widget::DropdownInput(dropdown_input) => {
let update_value = value.as_u64().expect("DropdownInput update was not of type: u64");
dropdown_input.selected_index = update_value as u32;
let callback_message = (dropdown_input.menu_entries.iter().flatten().nth(update_value as usize).unwrap().on_update.callback)(&());
responses.push_back(callback_message);
}
Widget::FontInput(font_input) => {
let update_value = value.as_object().expect("FontInput update was not of type: object");
let font_family_value = update_value.get("fontFamily").expect("FontInput update does not have a fontFamily");
+33
View File
@@ -152,6 +152,7 @@ impl<T> Default for WidgetCallback<T> {
pub enum Widget {
CheckboxInput(CheckboxInput),
ColorInput(ColorInput),
DropdownInput(DropdownInput),
FontInput(FontInput),
IconButton(IconButton),
IconLabel(IconLabel),
@@ -338,6 +339,38 @@ pub struct PopoverButton {
pub text: String,
}
#[derive(Clone, Serialize, Deserialize, Derivative, Default)]
#[derivative(Debug, PartialEq)]
pub struct DropdownInput {
#[serde(rename = "menuEntries")]
pub menu_entries: Vec<Vec<DropdownEntryData>>,
// This uses `u32` instead of `usize` since it will be serialized as a normal JS number
// TODO(mfish33): Replace with usize when using native UI
#[serde(rename = "selectedIndex")]
pub selected_index: u32,
#[serde(rename = "drawIcon")]
pub draw_icon: bool,
}
#[derive(Clone, Serialize, Deserialize, Derivative, Default)]
#[derivative(Debug, PartialEq)]
pub struct DropdownEntryData {
pub value: String,
pub label: String,
pub icon: String,
pub checkbox: bool,
pub shortcut: Vec<String>,
#[serde(rename = "shortcutRequiresLock")]
pub shortcut_requires_lock: bool,
pub children: Vec<Vec<DropdownEntryData>>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
}
#[derive(Clone, Serialize, Deserialize, Derivative, Default)]
#[derivative(Debug, PartialEq)]
pub struct RadioInput {