Break apart and improve the JS for rasterizing SVGs and downloading files (#786)
It now uses a blob URL instead of a data URL in the download process, which is cleaner and better performance.
This commit is contained in:
parent
dccff784c5
commit
35877a3fd9
|
|
@ -91,7 +91,7 @@ impl MessageHandler<DialogMessage, &PortfolioMessageHandler> for DialogMessageHa
|
||||||
DialogMessage::RequestNewDocumentDialog => {
|
DialogMessage::RequestNewDocumentDialog => {
|
||||||
self.new_document_dialog = NewDocumentDialogMessageHandler {
|
self.new_document_dialog = NewDocumentDialogMessageHandler {
|
||||||
name: portfolio.generate_new_document_name(),
|
name: portfolio.generate_new_document_name(),
|
||||||
infinite: true,
|
infinite: false,
|
||||||
dimensions: glam::UVec2::new(1920, 1080),
|
dimensions: glam::UVec2::new(1920, 1080),
|
||||||
};
|
};
|
||||||
self.new_document_dialog.register_properties(responses, LayoutTarget::DialogDetails);
|
self.new_document_dialog.register_properties(responses, LayoutTarget::DialogDetails);
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub enum FrontendMessage {
|
||||||
TriggerOpenDocument,
|
TriggerOpenDocument,
|
||||||
TriggerPaste,
|
TriggerPaste,
|
||||||
TriggerRasterDownload {
|
TriggerRasterDownload {
|
||||||
document: String,
|
svg: String,
|
||||||
name: String,
|
name: String,
|
||||||
mime: String,
|
mime: String,
|
||||||
size: (f64, f64),
|
size: (f64, f64),
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,7 @@ impl MessageHandler<DocumentMessage, (&InputPreprocessorMessageHandler, &FontCac
|
||||||
self.graphene_document.root.transform = DAffine2::IDENTITY;
|
self.graphene_document.root.transform = DAffine2::IDENTITY;
|
||||||
GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root);
|
GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root);
|
||||||
|
|
||||||
// Calculates the bounding box of the region to be exported
|
// Calculate the bounding box of the region to be exported
|
||||||
use crate::messages::frontend::utility_types::ExportBounds;
|
use crate::messages::frontend::utility_types::ExportBounds;
|
||||||
let bbox = match bounds {
|
let bbox = match bounds {
|
||||||
ExportBounds::AllArtwork => self.all_layer_bounds(font_cache),
|
ExportBounds::AllArtwork => self.all_layer_bounds(font_cache),
|
||||||
|
|
@ -345,7 +345,7 @@ impl MessageHandler<DocumentMessage, (&InputPreprocessorMessageHandler, &FontCac
|
||||||
} else {
|
} else {
|
||||||
let mime = file_type.to_mime().to_string();
|
let mime = file_type.to_mime().to_string();
|
||||||
let size = (size * scale_factor).into();
|
let size = (size * scale_factor).into();
|
||||||
responses.push_back(FrontendMessage::TriggerRasterDownload { document, name, mime, size }.into());
|
responses.push_back(FrontendMessage::TriggerRasterDownload { svg: document, name, mime, size }.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FlipSelectedLayers { flip_axis } => {
|
FlipSelectedLayers { flip_axis } => {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconLabel :icon="'NodeImage'" :iconStyle="'Node'" />
|
<IconLabel :icon="'NodeMask'" :iconStyle="'Node'" />
|
||||||
<TextLabel>Mask</TextLabel>
|
<TextLabel>Mask</TextLabel>
|
||||||
</div>
|
</div>
|
||||||
<div class="arguments">
|
<div class="arguments">
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,15 @@ export function createBlobManager(editor: Editor): void {
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
|
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
|
||||||
updateImageData.imageData.forEach(async (element) => {
|
updateImageData.imageData.forEach(async (element) => {
|
||||||
// Using updateImageData.imageData.buffer returns undefined for some reason?
|
// Using updateImageData.imageData.buffer returns undefined for some reason?
|
||||||
const blob = new Blob([new Uint8Array(element.imageData.values()).buffer], { type: element.mime });
|
const buffer = new Uint8Array(element.imageData.values()).buffer;
|
||||||
|
const blob = new Blob([buffer], { type: element.mime });
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
// TODO: Call `URL.revokeObjectURL` at the appropriate time to avoid a memory leak
|
||||||
|
const blobURL = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const image = await createImageBitmap(blob);
|
const image = await createImageBitmap(blob);
|
||||||
|
|
||||||
editor.instance.setImageBlobUrl(element.path, url, image.width, image.height);
|
editor.instance.setImageBlobUrl(element.path, blobURL, image.width, image.height);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
import { reactive, readonly } from "vue";
|
import { reactive, readonly } from "vue";
|
||||||
|
|
||||||
import { download, downloadBlob, upload } from "@/utility-functions/files";
|
import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files";
|
||||||
|
import { rasterizeSVG } from "@/utility-functions/rasterization";
|
||||||
import { type Editor } from "@/wasm-communication/editor";
|
import { type Editor } from "@/wasm-communication/editor";
|
||||||
import {
|
import {
|
||||||
type FrontendDocumentDetails,
|
type FrontendDocumentDetails,
|
||||||
|
|
@ -40,40 +41,19 @@ export function createPortfolioState(editor: Editor) {
|
||||||
editor.instance.pasteImage(data.type, Uint8Array.from(data.content));
|
editor.instance.pasteImage(data.type, Uint8Array.from(data.content));
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
||||||
download(triggerFileDownload.name, triggerFileDownload.document);
|
downloadFileText(triggerFileDownload.name, triggerFileDownload.document);
|
||||||
});
|
});
|
||||||
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, (triggerRasterDownload) => {
|
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, async (triggerRasterDownload) => {
|
||||||
// A canvas to render our svg to in order to get a raster image
|
const { svg, name, mime, size } = triggerRasterDownload;
|
||||||
// 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 context = canvas.getContext("2d");
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
// Fill the canvas with white if jpeg (does not support transparency and defaults to black)
|
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
||||||
if (triggerRasterDownload.mime.endsWith("jpeg")) {
|
const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined;
|
||||||
context.fillStyle = "white";
|
|
||||||
context.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a blob url for our svg
|
// Rasterize the SVG to an image file
|
||||||
const img = new Image();
|
const blob = await rasterizeSVG(svg, size.x, size.y, mime, backgroundColor);
|
||||||
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
|
|
||||||
context?.drawImage(img, 0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y);
|
|
||||||
|
|
||||||
// Convert the canvas to an image of the correct mime
|
// Have the browser download the file to the user's disk
|
||||||
const imgURI = canvas.toDataURL(triggerRasterDownload.mime);
|
downloadFileBlob(name, blob);
|
||||||
// Download our canvas
|
|
||||||
downloadBlob(imgURI, triggerRasterDownload.name);
|
|
||||||
|
|
||||||
// Cleanup resources
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
img.src = url;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
export function downloadBlob(url: string, filename: string): void {
|
export function downloadFileURL(filename: string, url: string): void {
|
||||||
const element = document.createElement("a");
|
const element = document.createElement("a");
|
||||||
|
|
||||||
element.href = url;
|
element.href = url;
|
||||||
element.setAttribute("download", filename);
|
element.setAttribute("download", filename);
|
||||||
element.style.display = "none";
|
|
||||||
|
|
||||||
element.click();
|
element.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function download(filename: string, fileData: string): void {
|
export function downloadFileBlob(filename: string, blob: Blob): 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);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
downloadBlob(url, filename);
|
downloadFileURL(filename, url);
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function downloadFileText(filename: string, text: string): void {
|
||||||
|
const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "text/plain;charset=utf-8";
|
||||||
|
|
||||||
|
const blob = new Blob([text], { type });
|
||||||
|
downloadFileBlob(filename, blob);
|
||||||
|
}
|
||||||
|
|
||||||
export async function upload<T extends "text" | "data">(acceptedExtensions: string, textOrData: T): Promise<UploadResult<T>> {
|
export async function upload<T extends "text" | "data">(acceptedExtensions: string, textOrData: T): Promise<UploadResult<T>> {
|
||||||
return new Promise<UploadResult<T>>((resolve, _) => {
|
return new Promise<UploadResult<T>>((resolve, _) => {
|
||||||
const element = document.createElement("input");
|
const element = document.createElement("input");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
||||||
|
export function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||||
|
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
|
||||||
|
let promiseReject: () => void | undefined;
|
||||||
|
const promise = new Promise<Blob>((resolve, reject) => {
|
||||||
|
promiseResolve = resolve;
|
||||||
|
promiseReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return Promise.reject();
|
||||||
|
|
||||||
|
// Apply a background fill color if one is given
|
||||||
|
if (backgroundColor) {
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a blob URL for our SVG
|
||||||
|
const image = new Image();
|
||||||
|
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
image.onload = (): void => {
|
||||||
|
// Draw our SVG to the canvas
|
||||||
|
context?.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Convert the canvas to an image of the correct MIME type
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob !== null) promiseResolve(blob);
|
||||||
|
else promiseReject();
|
||||||
|
}, mime);
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
@ -222,7 +222,7 @@ export class TriggerImport extends JsMessage {}
|
||||||
export class TriggerPaste extends JsMessage {}
|
export class TriggerPaste extends JsMessage {}
|
||||||
|
|
||||||
export class TriggerRasterDownload extends JsMessage {
|
export class TriggerRasterDownload extends JsMessage {
|
||||||
readonly document!: string;
|
readonly svg!: string;
|
||||||
|
|
||||||
readonly name!: string;
|
readonly name!: string;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue