diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 97f54cb0a5..0aa201fee5 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -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.); diff --git a/editor/src/dialog/dialog_message.rs b/editor/src/dialog/dialog_message.rs index bb5fb49c98..6d2d2fa059 100644 --- a/editor/src/dialog/dialog_message.rs +++ b/editor/src/dialog/dialog_message.rs @@ -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, }, + RequestExportDialog, RequestNewDocumentDialog, } diff --git a/editor/src/dialog/dialog_message_handler.rs b/editor/src/dialog/dialog_message_handler.rs index 269153237d..a6699acfff 100644 --- a/editor/src/dialog/dialog_message_handler.rs +++ b/editor/src/dialog/dialog_message_handler.rs @@ -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 f fn process_action(&mut self, message: DialogMessage, (build_metadata, portfolio): (&BuildMetadata, &PortfolioMessageHandler), responses: &mut VecDeque) { #[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 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 f } } - advertise_actions!(DialogMessageDiscriminant;RequestNewDocumentDialog,CloseAllDocumentsWithConfirmation); + advertise_actions!(DialogMessageDiscriminant;RequestNewDocumentDialog,RequestExportDialog,CloseAllDocumentsWithConfirmation); } diff --git a/editor/src/dialog/dialogs/export_dialog.rs b/editor/src/dialog/dialogs/export_dialog.rs new file mode 100644 index 0000000000..776b1f54e7 --- /dev/null +++ b/editor/src/dialog/dialogs/export_dialog.rs @@ -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, +} + +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 for Export { + fn process_action(&mut self, action: ExportDialogUpdate, _data: (), responses: &mut VecDeque) { + 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;} +} diff --git a/editor/src/dialog/dialogs/mod.rs b/editor/src/dialog/dialogs/mod.rs index 986fae4d11..ad15ea554b 100644 --- a/editor/src/dialog/dialogs/mod.rs +++ b/editor/src/dialog/dialogs/mod.rs @@ -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}; diff --git a/editor/src/document/document_message.rs b/editor/src/document/document_message.rs index 5f4ba2bb57..6254237a72 100644 --- a/editor/src/document/document_message.rs +++ b/editor/src/document/document_message.rs @@ -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, }, diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index cbd168828a..50d57f4ad9 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -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 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#"{}{}"#, - 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#"{}{}"#, + 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); diff --git a/editor/src/frontend/frontend_message.rs b/editor/src/frontend/frontend_message.rs index c3fd587501..3143e25561 100644 --- a/editor/src/frontend/frontend_message.rs +++ b/editor/src/frontend/frontend_message.rs @@ -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, diff --git a/editor/src/frontend/utility_types.rs b/editor/src/frontend/utility_types.rs index 17970bfbc8..41a6ec2416 100644 --- a/editor/src/frontend/utility_types.rs +++ b/editor/src/frontend/utility_types.rs @@ -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 } } diff --git a/editor/src/input/input_mapper.rs b/editor/src/input/input_mapper.rs index 835717a7a8..4e969e5913 100644 --- a/editor/src/input/input_mapper.rs +++ b/editor/src/input/input_mapper.rs @@ -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}, diff --git a/editor/src/layout/layout_message_handler.rs b/editor/src/layout/layout_message_handler.rs index cf9ae0c118..6fe6e0f768 100644 --- a/editor/src/layout/layout_message_handler.rs +++ b/editor/src/layout/layout_message_handler.rs @@ -70,6 +70,12 @@ impl MessageHandler 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"); diff --git a/editor/src/layout/widgets.rs b/editor/src/layout/widgets.rs index 78c8660ebb..3f28cd6e68 100644 --- a/editor/src/layout/widgets.rs +++ b/editor/src/layout/widgets.rs @@ -152,6 +152,7 @@ impl Default for WidgetCallback { 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>, + + // 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, + #[serde(rename = "shortcutRequiresLock")] + pub shortcut_requires_lock: bool, + pub children: Vec>, + + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_update: WidgetCallback<()>, +} + #[derive(Clone, Serialize, Deserialize, Derivative, Default)] #[derivative(Debug, PartialEq)] pub struct RadioInput { diff --git a/frontend/src/components/widgets/WidgetRow.vue b/frontend/src/components/widgets/WidgetRow.vue index 4079b07f61..431f6c9c0f 100644 --- a/frontend/src/components/widgets/WidgetRow.vue +++ b/frontend/src/components/widgets/WidgetRow.vue @@ -4,6 +4,7 @@ + .floating-menu-container > .floating-menu-content { pointer-events: auto; padding: 24px; } @@ -50,7 +50,7 @@ .main-column { margin: -4px 0; - .details { + .details.text-label { user-select: text; white-space: pre-wrap; max-width: 400px; diff --git a/frontend/src/components/widgets/floating-menus/FloatingMenu.vue b/frontend/src/components/widgets/floating-menus/FloatingMenu.vue index e6971682d6..febc19df40 100644 --- a/frontend/src/components/widgets/floating-menus/FloatingMenu.vue +++ b/frontend/src/components/widgets/floating-menus/FloatingMenu.vue @@ -114,7 +114,7 @@ justify-content: center; align-items: center; - .floating-menu-content { + > .floating-menu-container > .floating-menu-content { transform: translate(-50%, -50%); } } @@ -221,20 +221,24 @@ export default defineComponent({ this.floatingMenuBounds = floatingMenu.getBoundingClientRect(); this.floatingMenuContentBounds = floatingMenuContent.getBoundingClientRect(); - // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) - const tailOffset = this.type === "Popover" ? 10 : 0; - if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`; - if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`; - if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`; - if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`; - - // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) - const tail = this.$refs.tail as HTMLElement; - if (tail) { - if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`; - if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`; - if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`; - if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`; + const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]")); + + if (!inParentFloatingMenu) { + // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) + const tailOffset = this.type === "Popover" ? 10 : 0; + if (this.direction === "Bottom") floatingMenuContent.style.top = `${tailOffset + this.floatingMenuBounds.top}px`; + if (this.direction === "Top") floatingMenuContent.style.bottom = `${tailOffset + this.floatingMenuBounds.bottom}px`; + if (this.direction === "Right") floatingMenuContent.style.left = `${tailOffset + this.floatingMenuBounds.left}px`; + if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`; + + // Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping) + const tail = this.$refs.tail as HTMLElement; + if (tail) { + if (this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`; + if (this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`; + if (this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`; + if (this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`; + } } type Edge = "Top" | "Bottom" | "Left" | "Right"; diff --git a/frontend/src/dispatcher/js-messages.ts b/frontend/src/dispatcher/js-messages.ts index b3ce0ffe03..c156155ae0 100644 --- a/frontend/src/dispatcher/js-messages.ts +++ b/frontend/src/dispatcher/js-messages.ts @@ -221,6 +221,17 @@ export class TriggerFileDownload extends JsMessage { export class TriggerFileUpload extends JsMessage {} +export class TriggerRasterDownload extends JsMessage { + readonly document!: string; + + readonly name!: string; + + readonly mime!: string; + + @TupleToVec2 + readonly size!: { x: number; y: number }; +} + export class DocumentChanged extends JsMessage {} export class DisplayDocumentLayerTreeStructure extends JsMessage { @@ -433,6 +444,7 @@ export function isWidgetSection(layoutRow: WidgetRow | WidgetSection): layoutRow export type WidgetKind = | "CheckboxInput" | "ColorInput" + | "DropdownInput" | "FontInput" | "IconButton" | "IconLabel" @@ -545,6 +557,7 @@ export const messageMakers: Record = { TriggerIndexedDbRemoveDocument, TriggerFontLoad, TriggerIndexedDbWriteDocument, + TriggerRasterDownload, TriggerTextCommit, TriggerTextCopy, TriggerViewportResize, diff --git a/frontend/src/state/documents.ts b/frontend/src/state/documents.ts index da6e52b8b9..ba74c437c8 100644 --- a/frontend/src/state/documents.ts +++ b/frontend/src/state/documents.ts @@ -1,9 +1,9 @@ /* eslint-disable max-classes-per-file */ import { reactive, readonly } from "vue"; -import { TriggerFileDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/dispatcher/js-messages"; +import { TriggerFileDownload, TriggerRasterDownload, FrontendDocumentDetails, TriggerFileUpload, UpdateActiveDocument, UpdateOpenDocumentsList } from "@/dispatcher/js-messages"; import { EditorState } from "@/state/wasm-loader"; -import { download, upload } from "@/utilities/files"; +import { download, downloadBlob, upload } from "@/utilities/files"; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createDocumentsState(editor: EditorState) { @@ -34,6 +34,40 @@ export function createDocumentsState(editor: EditorState) { download(triggerFileDownload.name, triggerFileDownload.document); }); + editor.dispatcher.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => { + // A canvas to render our svg to in order to get a raster image + // https://stackoverflow.com/questions/3975499/convert-svg-to-image-jpeg-png-etc-in-the-browser + const canvas = document.createElement("canvas"); + canvas.width = triggerRasterDownload.size.x; + canvas.height = triggerRasterDownload.size.y; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Fill the canvas with white if jpeg (does not support transparency and defaults to black) + if (triggerRasterDownload.mime.endsWith("jpg")) { + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); + } + + // Create a blob url for our svg + const img = new Image(); + const svgBlob = new Blob([triggerRasterDownload.document], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + img.onload = (): void => { + // Draw our svg to the canvas + ctx?.drawImage(img, 0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); + + // Convert the canvas to an image of the correct mime + const imgURI = canvas.toDataURL(triggerRasterDownload.mime); + // Download our canvas + downloadBlob(imgURI, triggerRasterDownload.name); + + // Cleanup resources + URL.revokeObjectURL(url); + }; + img.src = url; + }); + // TODO(mfish33): Replace with initialization system Issue:#524 // Get the initial documents editor.instance.get_open_documents_list(); diff --git a/frontend/src/utilities/files.ts b/frontend/src/utilities/files.ts index d5068e8bcb..137e427a79 100644 --- a/frontend/src/utilities/files.ts +++ b/frontend/src/utilities/files.ts @@ -1,7 +1,4 @@ -export function download(filename: string, fileData: string): void { - const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "text/plain;charset=utf-8"; - const blob = new Blob([fileData], { type }); - const url = URL.createObjectURL(blob); +export function downloadBlob(url: string, filename: string): void { const element = document.createElement("a"); element.href = url; @@ -11,6 +8,16 @@ export function download(filename: string, fileData: string): void { element.click(); } +export function download(filename: string, fileData: string): void { + const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "text/plain;charset=utf-8"; + const blob = new Blob([fileData], { type }); + const url = URL.createObjectURL(blob); + + downloadBlob(url, filename); + + URL.revokeObjectURL(url); +} + export async function upload(acceptedEextensions: string): Promise<{ filename: string; content: string }> { return new Promise<{ filename: string; content: string }>((resolve, _) => { const element = document.createElement("input"); diff --git a/frontend/wasm/src/api.rs b/frontend/wasm/src/api.rs index 83a14da827..18fb855922 100644 --- a/frontend/wasm/src/api.rs +++ b/frontend/wasm/src/api.rs @@ -487,7 +487,7 @@ impl JsEditorHandle { /// Export the document pub fn export_document(&self) { - let message = DocumentMessage::ExportDocument; + let message = DialogMessage::RequestExportDialog; self.dispatch(message); }