From 5a6fef7a18e88fb61cfd80feb9d17fcec50eed6a Mon Sep 17 00:00:00 2001 From: Timon Date: Sat, 18 Jul 2026 09:29:35 +0000 Subject: [PATCH 1/2] Move file filter creation to the editor --- .../wrapper/src/intercept_frontend_message.rs | 21 ++++++------- desktop/wrapper/src/messages.rs | 7 +---- .../src/messages/frontend/frontend_message.rs | 12 +++++-- editor/src/messages/frontend/utility_types.rs | 7 +++++ .../document/document_message_handler.rs | 5 +++ .../portfolio/portfolio_message_handler.rs | 31 +++++++++++++++++-- editor/src/node_graph_executor.rs | 12 ++++++- frontend/src/stores/portfolio.ts | 11 +++---- frontend/src/utility-functions/files.ts | 9 ++++-- frontend/src/utility-functions/network.ts | 4 +-- frontend/wrapper/src/editor_wrapper.rs | 10 ------ 11 files changed, 84 insertions(+), 45 deletions(-) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index b82b837929..b188e39041 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -3,25 +3,25 @@ use graphite_editor::messages::layout::utility_types::layout_widget::LayoutTarge use graphite_editor::messages::prelude::FrontendMessage; use super::DesktopWrapperMessageDispatcher; -use super::messages::{DesktopFrontendMessage, FileFilter, OpenFileDialogContext, SaveFileDialogContext}; +use super::messages::{DesktopFrontendMessage, OpenFileDialogContext, SaveFileDialogContext}; pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: FrontendMessage) -> Option { match message { FrontendMessage::RenderOverlays { context } => { dispatcher.respond(DesktopFrontendMessage::UpdateOverlays(context.take_scene())); } - FrontendMessage::TriggerOpen => { + FrontendMessage::TriggerOpen { filters } => { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Open Document".to_string(), - filters: vec![], + filters, multiple: true, context: OpenFileDialogContext::Open, }); } - FrontendMessage::TriggerImport => { + FrontendMessage::TriggerImport { filters } => { dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Import File".to_string(), - filters: vec![], + filters, multiple: false, context: OpenFileDialogContext::Import, }); @@ -31,32 +31,29 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD name, path, folder, + filters, content, } => { let content = content.into_vec(); if let Some(path) = path { dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content }); } else { - let extension = std::path::Path::new(&name).extension().and_then(|extension| extension.to_str()).unwrap_or("*").to_string(); dispatcher.respond(DesktopFrontendMessage::SaveFileDialog { title: "Save Document".to_string(), default_filename: name, default_folder: folder, - filters: vec![FileFilter { - name: "Graphite Document".to_string(), - extensions: vec![extension], - }], + filters, context: SaveFileDialogContext::Document { document_id, content }, }); } } - FrontendMessage::TriggerSaveFile { name, folder, content } => { + FrontendMessage::TriggerSaveFile { name, folder, filters, content } => { let content = content.into_vec(); dispatcher.respond(DesktopFrontendMessage::SaveFileDialog { title: "Save File".to_string(), default_filename: name, default_folder: folder, - filters: Vec::new(), + filters, context: SaveFileDialogContext::File { content }, }); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 031a1cc207..746add6a49 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; -pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, PersistedState}; +pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState}; pub use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys}; pub use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys}; pub use graphite_editor::messages::prelude::DocumentId; @@ -100,11 +100,6 @@ pub enum DesktopWrapperMessage { LoadThirdPartyLicenses { text: String }, } -pub struct FileFilter { - pub name: String, - pub extensions: Vec, -} - #[derive(Clone, Copy)] pub enum OpenFileDialogContext { Open, diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index e227c6821a..60e87fc80b 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -1,7 +1,7 @@ use super::IconName; use super::utility_types::{MouseCursorIcon, PersistedState}; use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; -use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, RasterizedImage}; +use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileFilter, RasterizedImage}; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::{ @@ -87,16 +87,24 @@ pub enum FrontendMessage { commit_date: String, }, TriggerDisplayThirdPartyLicensesDialog, + TriggerOpen { + filters: Vec, + }, + TriggerImport { + filters: Vec, + }, TriggerSaveDocument { document_id: DocumentId, name: String, path: Option, folder: Option, + filters: Vec, content: serde_bytes::ByteBuf, }, TriggerSaveFile { name: String, folder: Option, + filters: Vec, content: serde_bytes::ByteBuf, }, TriggerExportImage { @@ -128,8 +136,6 @@ pub enum FrontendMessage { }, TriggerOpenLaunchDocuments, TriggerLoadPreferences, - TriggerOpen, - TriggerImport, TriggerSavePreferences { #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] preferences: PreferencesMessageHandler, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index bf614b1eea..abd9d1af9c 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -91,3 +91,10 @@ pub struct RasterizedImage { pub height: u32, pub pixels: serde_bytes::ByteBuf, } + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FileFilter { + pub name: String, + pub extensions: Vec, +} diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index a7c57f4659..f2de79fba0 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -10,6 +10,7 @@ use crate::consts::{ ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL, }; +use crate::messages::frontend::utility_types::FileFilter; use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler}; @@ -1085,6 +1086,10 @@ impl MessageHandler> for DocumentMes name, path, folder, + filters: vec![FileFilter { + name: "Graphite Document".into(), + extensions: vec![extension.into()], + }], content: content.into(), }) }); diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 5dbd6fb55c..b6e189372c 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -5,7 +5,7 @@ use crate::application::{Editor, generate_uuid}; use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION}; use crate::messages::animation::TimingInformation; use crate::messages::dialog::simple_dialogs; -use crate::messages::frontend::utility_types::{DocumentInfo, PersistedState}; +use crate::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::input_mapper::utility_types::macros::{action_shortcut, action_shortcut_manual}; use crate::messages::layout::utility_types::widget_prelude::*; @@ -501,6 +501,10 @@ impl MessageHandler> for Portfolio responses.add(FrontendMessage::TriggerSaveFile { name: filename, folder: None, + filters: vec![FileFilter { + name: "Graphite Document".into(), + extensions: vec![FILE_EXTENSION.into()], + }], content: serde_bytes::ByteBuf::from(content), }); } else { @@ -508,6 +512,10 @@ impl MessageHandler> for Portfolio Ok(zip_bytes) => responses.add(FrontendMessage::TriggerSaveFile { name: format!("{FOLDER_NAME}.zip"), folder: None, + filters: vec![FileFilter { + name: "Zip Archive".into(), + extensions: vec!["zip".into()], + }], content: serde_bytes::ByteBuf::from(zip_bytes), }), Err(e) => { @@ -652,11 +660,28 @@ impl MessageHandler> for Portfolio } PortfolioMessage::Open => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages - responses.add(FrontendMessage::TriggerOpen); + responses.add(FrontendMessage::TriggerOpen { + filters: vec![ + FileFilter { + name: "Graphite Document".into(), + extensions: vec![FILE_EXTENSION.into(), GDD_FILE_EXTENSION.into()], + }, + FileFilter { + name: "Image".into(), + extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".into()], + }, + ], + }); } PortfolioMessage::Import => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages - responses.add(FrontendMessage::TriggerImport); + // TODO: Also offer the Graphite document filter once importing Graphite documents as nodes is supported + responses.add(FrontendMessage::TriggerImport { + filters: vec![FileFilter { + name: "Image".into(), + extensions: vec!["svg".into(), "png".into(), "jpg".into(), "jpeg".into(), "bmp".into(), "gif".into()], + }], + }); } PortfolioMessage::OpenFile { path, content } => { let name = path.file_stem().map(|n| n.to_string_lossy().to_string()); diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index c58203f714..99fda06bcb 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -1,4 +1,4 @@ -use crate::messages::frontend::utility_types::{ExportBounds, FileType, RasterizedImage}; +use crate::messages::frontend::utility_types::{ExportBounds, FileFilter, FileType, RasterizedImage}; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::*; use glam::{DAffine2, DVec2, UVec2}; @@ -743,6 +743,14 @@ impl NodeGraphExecutor { }; let name = format!("{base_name}.{file_extension}"); let folder = document.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); + let filters = vec![FileFilter { + name: match file_type { + FileType::Svg => "SVG Image".into(), + FileType::Png => "PNG Image".into(), + FileType::Jpg => "JPEG Image".into(), + }, + extensions: vec![file_extension.into()], + }]; match node_graph_output { TaggedValue::RenderOutput(RenderOutput { @@ -753,6 +761,7 @@ impl NodeGraphExecutor { responses.add(FrontendMessage::TriggerSaveFile { name, folder, + filters, content: svg.into_bytes().into(), }); } else { @@ -806,6 +815,7 @@ impl NodeGraphExecutor { responses.add(FrontendMessage::TriggerSaveFile { name, folder, + filters, content: encoded.into(), }); } diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 3de2524d18..a77f40c201 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -3,7 +3,7 @@ import { SvelteMap } from "svelte/reactivity"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; -import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; +import { acceptStringFromFilters, downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; import { patchLayout } from "/src/utility-functions/widgets"; import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -93,14 +93,13 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); - subscriptions.subscribeFrontendMessage("TriggerOpen", async () => { - const files = await upload(`image/*,.${editor.fileExtension()},.${editor.gddFileExtension()}`, "data", true); + subscriptions.subscribeFrontendMessage("TriggerOpen", async ({ filters }) => { + const files = await upload(acceptStringFromFilters(filters), "data", true); files.forEach((file) => editor.openFile(file.filename, file.content)); }); - subscriptions.subscribeFrontendMessage("TriggerImport", async () => { - // TODO: Use the same `accept` string as in the `TriggerOpen` handler once importing Graphite documents as nodes is supported - const data = await upload("image/*", "data"); + subscriptions.subscribeFrontendMessage("TriggerImport", async ({ filters }) => { + const data = await upload(acceptStringFromFilters(filters), "data"); editor.importFile(data.filename, data.content); }); diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index aaf9dbe9d0..2af388764f 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -1,5 +1,5 @@ import { extractPixelData } from "/src/utility-functions/rasterization"; -import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { EditorWrapper, FileFilter } from "/wrapper/pkg/graphite_wasm_wrapper"; export function downloadFileURL(filename: string, url: string) { const element = document.createElement("a"); @@ -91,8 +91,13 @@ export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, m } else if (file.type.startsWith("image/")) { const imageData = await extractPixelData(file); editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex); - } else if (file.name.endsWith("." + editor.fileExtension())) { + } else { + // The editor classifies the file by its extension and shows an error dialog if it is unsupported // TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab editor.openFile(file.name, await file.bytes()); } } + +export function acceptStringFromFilters(filters: FileFilter[]): string { + return filters.flatMap((filter) => filter.extensions.map((extension) => `.${extension}`)).join(","); +} diff --git a/frontend/src/utility-functions/network.ts b/frontend/src/utility-functions/network.ts index 9394d42c5d..9a3c1ce556 100644 --- a/frontend/src/utility-functions/network.ts +++ b/frontend/src/utility-functions/network.ts @@ -40,11 +40,11 @@ export async function loadDemoArtwork(editor: EditorWrapper) { if (!demoArtwork) return; try { - const url = new URL(`/demo-artwork/${demoArtwork}.${editor.fileExtension()}`, document.location.href); + const url = new URL(`/demo-artwork/${demoArtwork}.graphite`, document.location.href); const response = await fetch(url); if (!response.ok) throw new Error(); - const filename = url.pathname.split("/").pop() || `Untitled.${editor.fileExtension()}`; + const filename = url.pathname.split("/").pop() || "Untitled.graphite"; const content = await response.bytes(); editor.openFile(filename, content); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 4dc1f9ce1e..c2d238a645 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -240,16 +240,6 @@ impl EditorWrapper { cfg!(debug_assertions) } - #[wasm_bindgen(js_name = fileExtension)] - pub fn file_extension(&self) -> String { - "graphite".into() - } - - #[wasm_bindgen(js_name = gddFileExtension)] - pub fn gdd_file_extension(&self) -> String { - "gdd".into() - } - /// Load persisted browser storage state (web only; on desktop, persistence is handled natively and this is never triggered) #[cfg(all(feature = "web", not(feature = "native")))] #[wasm_bindgen(js_name = loadPersistedState)] From cac63355cd038b4b2bc183d2aca21fb0a90c46c7 Mon Sep 17 00:00:00 2001 From: Timon Date: Sat, 18 Jul 2026 10:04:13 +0000 Subject: [PATCH 2/2] Review --- editor/src/messages/frontend/utility_types.rs | 20 +++++++++++++++++++ editor/src/node_graph_executor.rs | 18 +++-------------- frontend/src/utility-functions/files.ts | 5 +++-- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index abd9d1af9c..f5d31dcfd8 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -64,6 +64,26 @@ impl FileType { FileType::Svg => "image/svg+xml", } } + + pub fn extension(self) -> &'static str { + match self { + FileType::Png => "png", + FileType::Jpg => "jpg", + FileType::Svg => "svg", + } + } + + pub fn file_filter(self) -> FileFilter { + let name = match self { + FileType::Png => "PNG Image", + FileType::Jpg => "JPEG Image", + FileType::Svg => "SVG Image", + }; + FileFilter { + name: name.into(), + extensions: vec![self.extension().into()], + } + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 99fda06bcb..1ab915c8f9 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -1,4 +1,4 @@ -use crate::messages::frontend::utility_types::{ExportBounds, FileFilter, FileType, RasterizedImage}; +use crate::messages::frontend::utility_types::{ExportBounds, FileType, RasterizedImage}; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; use crate::messages::prelude::*; use glam::{DAffine2, DVec2, UVec2}; @@ -732,25 +732,13 @@ impl NodeGraphExecutor { .. } = export_config; - let file_extension = match file_type { - FileType::Svg => "svg", - FileType::Png => "png", - FileType::Jpg => "jpg", - }; let base_name = match (artboard_name, artboard_count) { (Some(artboard_name), count) if count > 1 => format!("{name} - {artboard_name}"), _ => name, }; - let name = format!("{base_name}.{file_extension}"); + let name = format!("{base_name}.{}", file_type.extension()); let folder = document.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); - let filters = vec![FileFilter { - name: match file_type { - FileType::Svg => "SVG Image".into(), - FileType::Png => "PNG Image".into(), - FileType::Jpg => "JPEG Image".into(), - }, - extensions: vec![file_extension.into()], - }]; + let filters = vec![file_type.file_filter()]; match node_graph_output { TaggedValue::RenderOutput(RenderOutput { diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 2af388764f..1ab3273cff 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -92,12 +92,13 @@ export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, m const imageData = await extractPixelData(file); editor.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, mouse?.[0], mouse?.[1], insertParentId, insertIndex); } else { - // The editor classifies the file by its extension and shows an error dialog if it is unsupported // TODO: When we eventually have sub-documents, this should be changed to import the document as a node instead of opening it in a separate tab editor.openFile(file.name, await file.bytes()); } } export function acceptStringFromFilters(filters: FileFilter[]): string { - return filters.flatMap((filter) => filter.extensions.map((extension) => `.${extension}`)).join(","); + const extensions = filters.flatMap((filter) => filter.extensions); + const imageMime = extensions.some((extension) => ["svg", "png", "jpg", "jpeg", "bmp", "gif", "webp", "avif", "tif", "tiff"].includes(extension)) ? ["image/*"] : []; + return [...imageMime, ...extensions.map((extension) => `.${extension}`)].join(","); }