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

@ -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,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`;
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
// 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`;
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";

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