mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-04 05:18:19 +00:00
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
19 changed files with 445 additions and 59 deletions
|
@ -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,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";
|
||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue