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
parent e3e506ecfb
commit 060182fd31
19 changed files with 445 additions and 59 deletions

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.);

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,
}

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);
}

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;}
}

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};

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,
},

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()
}
}
/// 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);

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,

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
}
}

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},

View File

@ -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");

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 {

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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();

View File

@ -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");

View File

@ -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);
}