mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 10:58:04 +08:00
Implement download/copy ImageFrame layer output (#1194)
* Implement download/copy ImageFrame layer output * Add note about black transparency when copying * Introspect node graph output type to conditionally disable the download button --------- Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { type Editor } from "@graphite/wasm-communication/editor";
|
||||
import { TriggerTextCopy } from "@graphite/wasm-communication/messages";
|
||||
import { imageToPNG } from "~src/utility-functions/rasterization";
|
||||
|
||||
export function createClipboardManager(editor: Editor): void {
|
||||
// Subscribe to process backend event
|
||||
@@ -8,3 +9,20 @@ export function createClipboardManager(editor: Editor): void {
|
||||
navigator.clipboard?.writeText?.(triggerTextCopy.copyText);
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyToClipboardFileURL(url: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// TODO: Remove this if/when we end up returning PNG directly from the backend
|
||||
const pngBlob = await imageToPNG(blob);
|
||||
|
||||
const clipboardItem: Record<string, Blob> = {};
|
||||
clipboardItem[pngBlob.type] = pngBlob;
|
||||
const data = [new ClipboardItem(clipboardItem)];
|
||||
|
||||
// Note: if this image has transparency, it will be lost and appear as black due to limitations of the way browsers handle copying transparent images
|
||||
// This even happens if you just open a regular transparent PNG file in a browser tab, right click > copy, and paste it somewhere (the transparency will show up as black)
|
||||
// This is true, at least, on Windows (it's worth checking on other OSs though)
|
||||
navigator.clipboard.write(data);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,28 @@
|
||||
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
import { downloadFileText, downloadFileBlob, upload } from "@graphite/utility-functions/files";
|
||||
import { downloadFileText, downloadFileBlob, upload, downloadFileURL } from "@graphite/utility-functions/files";
|
||||
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate, updateBackendImage } from "@graphite/utility-functions/imaginate";
|
||||
import { extractPixelData, rasterizeSVG, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
import { extractPixelData, imageToPNG, rasterizeSVG, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
|
||||
import { type Editor } from "@graphite/wasm-communication/editor";
|
||||
import {
|
||||
type FrontendDocumentDetails,
|
||||
TriggerFileDownload,
|
||||
TriggerImport,
|
||||
TriggerOpenDocument,
|
||||
TriggerRasterDownload,
|
||||
TriggerCopyToClipboardBlobUrl,
|
||||
TriggerDownloadBlobUrl,
|
||||
TriggerDownloadRaster,
|
||||
TriggerDownloadTextFile,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
TriggerImaginateGenerate,
|
||||
TriggerImaginateTerminate,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
TriggerImport,
|
||||
TriggerOpenDocument,
|
||||
TriggerRasterizeRegionBelowLayer,
|
||||
UpdateActiveDocument,
|
||||
UpdateOpenDocumentsList,
|
||||
UpdateImageData,
|
||||
TriggerRevokeBlobUrl,
|
||||
UpdateActiveDocument,
|
||||
UpdateImageData,
|
||||
UpdateOpenDocumentsList,
|
||||
} from "@graphite/wasm-communication/messages";
|
||||
import { copyToClipboardFileURL } from "~src/io-managers/clipboard";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function createPortfolioState(editor: Editor) {
|
||||
@@ -55,10 +58,22 @@ export function createPortfolioState(editor: Editor) {
|
||||
const imageData = await extractPixelData(new Blob([data.content], { type: data.type }));
|
||||
editor.instance.pasteImage(new Uint8Array(imageData.data), imageData.width, imageData.height);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerFileDownload, (triggerFileDownload) => {
|
||||
editor.subscriptions.subscribeJsMessage(TriggerDownloadTextFile, (triggerFileDownload) => {
|
||||
downloadFileText(triggerFileDownload.name, triggerFileDownload.document);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerRasterDownload, async (triggerRasterDownload) => {
|
||||
editor.subscriptions.subscribeJsMessage(TriggerDownloadBlobUrl, async (triggerDownloadBlobUrl) => {
|
||||
const data = await fetch(triggerDownloadBlobUrl.blobUrl);
|
||||
const blob = await data.blob();
|
||||
|
||||
// TODO: Remove this if/when we end up returning PNG directly from the backend
|
||||
const pngBlob = await imageToPNG(blob);
|
||||
|
||||
downloadFileBlob(triggerDownloadBlobUrl.layerName, pngBlob);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerCopyToClipboardBlobUrl, (triggerDownloadBlobUrl) => {
|
||||
copyToClipboardFileURL(triggerDownloadBlobUrl.blobUrl);
|
||||
});
|
||||
editor.subscriptions.subscribeJsMessage(TriggerDownloadRaster, async (triggerRasterDownload) => {
|
||||
const { svg, name, mime, size } = triggerRasterDownload;
|
||||
|
||||
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black)
|
||||
|
||||
@@ -56,6 +56,26 @@ export async function rasterizeSVG(svg: string, width: number, height: number, m
|
||||
|
||||
/// Convert an image source (e.g. PNG document) into pixel data, a width, and a height
|
||||
export async function extractPixelData(imageData: ImageBitmapSource): Promise<ImageData> {
|
||||
const canvasContext = await imageToCanvasContext(imageData);
|
||||
const width = canvasContext.canvas.width;
|
||||
const height = canvasContext.canvas.height;
|
||||
|
||||
return canvasContext.getImageData(0, 0, width, height);
|
||||
}
|
||||
|
||||
/// Convert an image source (e.g. BMP document) into a PNG blob
|
||||
export async function imageToPNG(imageData: ImageBitmapSource): Promise<Blob> {
|
||||
const canvasContext = await imageToCanvasContext(imageData);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvasContext.canvas.toBlob((pngBlob) => {
|
||||
if (pngBlob) resolve(pngBlob);
|
||||
else reject("Converting canvas to blob data failed in imageToPNG()");
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
export async function imageToCanvasContext(imageData: ImageBitmapSource): Promise<CanvasRenderingContext2D> {
|
||||
// Special handling to rasterize an SVG file
|
||||
let svgImageData;
|
||||
if (imageData instanceof File && imageData.type === "image/svg+xml") {
|
||||
@@ -92,5 +112,5 @@ export async function extractPixelData(imageData: ImageBitmapSource): Promise<Im
|
||||
if (!context) throw new Error("Could not create canvas context");
|
||||
context.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height);
|
||||
|
||||
return context.getImageData(0, 0, width, height);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -498,12 +498,6 @@ export class UpdateMouseCursor extends JsMessage {
|
||||
readonly cursor!: MouseCursorIcon;
|
||||
}
|
||||
|
||||
export class TriggerFileDownload extends JsMessage {
|
||||
readonly document!: string;
|
||||
|
||||
readonly name!: string;
|
||||
}
|
||||
|
||||
export class TriggerLoadAutoSaveDocuments extends JsMessage { }
|
||||
|
||||
export class TriggerLoadPreferences extends JsMessage { }
|
||||
@@ -514,7 +508,17 @@ export class TriggerImport extends JsMessage { }
|
||||
|
||||
export class TriggerPaste extends JsMessage { }
|
||||
|
||||
export class TriggerRasterDownload extends JsMessage {
|
||||
export class TriggerCopyToClipboardBlobUrl extends JsMessage {
|
||||
readonly blobUrl!: string;
|
||||
}
|
||||
|
||||
export class TriggerDownloadBlobUrl extends JsMessage {
|
||||
readonly layerName!: string;
|
||||
|
||||
readonly blobUrl!: string;
|
||||
}
|
||||
|
||||
export class TriggerDownloadRaster extends JsMessage {
|
||||
readonly svg!: string;
|
||||
|
||||
readonly name!: string;
|
||||
@@ -525,6 +529,12 @@ export class TriggerRasterDownload extends JsMessage {
|
||||
readonly size!: XY;
|
||||
}
|
||||
|
||||
export class TriggerDownloadTextFile extends JsMessage {
|
||||
readonly document!: string;
|
||||
|
||||
readonly name!: string;
|
||||
}
|
||||
|
||||
export class TriggerImaginateCheckServerStatus extends JsMessage {
|
||||
readonly hostname!: string;
|
||||
}
|
||||
@@ -1384,12 +1394,14 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
DisplayEditableTextboxTransform,
|
||||
DisplayRemoveEditableTextbox,
|
||||
TriggerAboutGraphiteLocalizedCommitDate,
|
||||
TriggerCopyToClipboardBlobUrl,
|
||||
TriggerDownloadBlobUrl,
|
||||
TriggerDownloadRaster,
|
||||
TriggerDownloadTextFile,
|
||||
TriggerFontLoad,
|
||||
TriggerImaginateCheckServerStatus,
|
||||
TriggerImaginateGenerate,
|
||||
TriggerImaginateTerminate,
|
||||
TriggerRasterizeRegionBelowLayer,
|
||||
TriggerFileDownload,
|
||||
TriggerFontLoad,
|
||||
TriggerImport,
|
||||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
@@ -1397,7 +1409,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
TriggerLoadPreferences,
|
||||
TriggerOpenDocument,
|
||||
TriggerPaste,
|
||||
TriggerRasterDownload,
|
||||
TriggerRasterizeRegionBelowLayer,
|
||||
TriggerRefreshBoundsOfViewports,
|
||||
TriggerRevokeBlobUrl,
|
||||
TriggerSavePreferences,
|
||||
@@ -1415,8 +1427,8 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateDocumentModeLayout,
|
||||
UpdateDocumentOverlays,
|
||||
UpdateDocumentRulers,
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateDocumentScrollbars,
|
||||
UpdateEyedropperSamplingState,
|
||||
UpdateImageData,
|
||||
UpdateInputHints,
|
||||
UpdateLayerTreeOptionsLayout,
|
||||
@@ -1429,9 +1441,9 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||
UpdateOpenDocumentsList,
|
||||
UpdatePropertyPanelOptionsLayout,
|
||||
UpdatePropertyPanelSectionsLayout,
|
||||
UpdateZoomWithScroll,
|
||||
UpdateToolOptionsLayout,
|
||||
UpdateToolShelfLayout,
|
||||
UpdateWorkingColorsLayout,
|
||||
UpdateZoomWithScroll,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageMakers;
|
||||
|
||||
Reference in New Issue
Block a user