From 35877a3fd9f37239d717a32c9f316ea2d8a2b422 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 8 Oct 2022 11:34:31 -0700 Subject: [PATCH] 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. --- .../messages/dialog/dialog_message_handler.rs | 2 +- .../src/messages/frontend/frontend_message.rs | 2 +- .../document/document_message_handler.rs | 4 +- frontend/src/components/panels/NodeGraph.vue | 2 +- frontend/src/io-managers/blob.ts | 8 ++-- frontend/src/state-providers/portfolio.ts | 42 +++++------------- frontend/src/utility-functions/files.ts | 16 ++++--- .../src/utility-functions/rasterization.ts | 44 +++++++++++++++++++ frontend/src/wasm-communication/messages.ts | 2 +- 9 files changed, 76 insertions(+), 46 deletions(-) create mode 100644 frontend/src/utility-functions/rasterization.ts diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index 818816fb..3a914e60 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -91,7 +91,7 @@ impl MessageHandler for DialogMessageHa DialogMessage::RequestNewDocumentDialog => { self.new_document_dialog = NewDocumentDialogMessageHandler { name: portfolio.generate_new_document_name(), - infinite: true, + infinite: false, dimensions: glam::UVec2::new(1920, 1080), }; self.new_document_dialog.register_properties(responses, LayoutTarget::DialogDetails); diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index aa7710c1..042a1aac 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -63,7 +63,7 @@ pub enum FrontendMessage { TriggerOpenDocument, TriggerPaste, TriggerRasterDownload { - document: String, + svg: String, name: String, mime: String, size: (f64, f64), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index baf70bb4..e0635d93 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -314,7 +314,7 @@ impl MessageHandler self.all_layer_bounds(font_cache), @@ -345,7 +345,7 @@ impl MessageHandler { diff --git a/frontend/src/components/panels/NodeGraph.vue b/frontend/src/components/panels/NodeGraph.vue index f456d3f3..f0fc2779 100644 --- a/frontend/src/components/panels/NodeGraph.vue +++ b/frontend/src/components/panels/NodeGraph.vue @@ -42,7 +42,7 @@
- + Mask
diff --git a/frontend/src/io-managers/blob.ts b/frontend/src/io-managers/blob.ts index 83ae598b..eb6871ff 100644 --- a/frontend/src/io-managers/blob.ts +++ b/frontend/src/io-managers/blob.ts @@ -6,13 +6,15 @@ export function createBlobManager(editor: Editor): void { editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => { updateImageData.imageData.forEach(async (element) => { // 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); - editor.instance.setImageBlobUrl(element.path, url, image.width, image.height); + editor.instance.setImageBlobUrl(element.path, blobURL, image.width, image.height); }); }); } diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index 17b25c24..f14d756d 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -1,7 +1,8 @@ /* eslint-disable max-classes-per-file */ 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 FrontendDocumentDetails, @@ -40,40 +41,19 @@ export function createPortfolioState(editor: Editor) { editor.instance.pasteImage(data.type, Uint8Array.from(data.content)); }); editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => { - download(triggerFileDownload.name, triggerFileDownload.document); + downloadFileText(triggerFileDownload.name, triggerFileDownload.document); }); - editor.subscriptions.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 context = canvas.getContext("2d"); - if (!context) return; + editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, async (triggerRasterDownload) => { + const { svg, name, mime, size } = triggerRasterDownload; - // Fill the canvas with white if jpeg (does not support transparency and defaults to black) - if (triggerRasterDownload.mime.endsWith("jpeg")) { - context.fillStyle = "white"; - context.fillRect(0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); - } + // Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black) + const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined; - // 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 - context?.drawImage(img, 0, 0, triggerRasterDownload.size.x, triggerRasterDownload.size.y); + // Rasterize the SVG to an image file + const blob = await rasterizeSVG(svg, size.x, size.y, mime, backgroundColor); - // 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; + // Have the browser download the file to the user's disk + downloadFileBlob(name, blob); }); return { diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 18b51d10..fd586874 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -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"); element.href = url; element.setAttribute("download", filename); - element.style.display = "none"; 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 }); +export function downloadFileBlob(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob); - downloadBlob(url, filename); + downloadFileURL(filename, 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(acceptedExtensions: string, textOrData: T): Promise> { return new Promise>((resolve, _) => { const element = document.createElement("input"); diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts new file mode 100644 index 00000000..f64355b7 --- /dev/null +++ b/frontend/src/utility-functions/rasterization.ts @@ -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 { + let promiseResolve: (value: Blob | PromiseLike) => void | undefined; + let promiseReject: () => void | undefined; + const promise = new Promise((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; +} diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index a83056b9..3f13d985 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -222,7 +222,7 @@ export class TriggerImport extends JsMessage {} export class TriggerPaste extends JsMessage {} export class TriggerRasterDownload extends JsMessage { - readonly document!: string; + readonly svg!: string; readonly name!: string;