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:
parent
e3e506ecfb
commit
060182fd31
|
|
@ -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.);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()]);
|
||||
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,
|
||||
};
|
||||
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,
|
||||
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 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,
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
FlipSelectedLayers { flip_axis } => {
|
||||
self.backup(responses);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<!-- TODO: Use `<component :is="" v-bind="attributesObject"></component>` to avoid all the separate components with `v-if` -->
|
||||
<CheckboxInput v-if="component.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
|
||||
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
|
||||
<DropdownInput v-if="component.kind === 'DropdownInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
|
||||
<FontInput
|
||||
v-if="component.kind === 'FontInput'"
|
||||
v-bind="component.props"
|
||||
|
|
@ -69,6 +70,7 @@ import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
|
|||
import TextButton from "@/components/widgets/buttons/TextButton.vue";
|
||||
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
||||
import ColorInput from "@/components/widgets/inputs/ColorInput.vue";
|
||||
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
||||
import FontInput from "@/components/widgets/inputs/FontInput.vue";
|
||||
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
|
||||
|
|
@ -104,6 +106,7 @@ export default defineComponent({
|
|||
IconButton,
|
||||
OptionalInput,
|
||||
RadioInput,
|
||||
DropdownInput,
|
||||
TextLabel,
|
||||
IconLabel,
|
||||
ColorInput,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.floating-menu-container .floating-menu-content {
|
||||
> .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;
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.floating-menu-content {
|
||||
> .floating-menu-container > .floating-menu-content {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
|
@ -221,6 +221,9 @@ export default defineComponent({
|
|||
this.floatingMenuBounds = floatingMenu.getBoundingClientRect();
|
||||
this.floatingMenuContentBounds = floatingMenuContent.getBoundingClientRect();
|
||||
|
||||
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`;
|
||||
|
|
@ -236,6 +239,7 @@ export default defineComponent({
|
|||
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";
|
||||
let zeroedBorderVertical: Edge | undefined;
|
||||
|
|
|
|||
|
|
@ -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<string, MessageMaker> = {
|
|||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerFontLoad,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
TriggerRasterDownload,
|
||||
TriggerTextCommit,
|
||||
TriggerTextCopy,
|
||||
TriggerViewportResize,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue