Move file filter creation from frontend to editor (#4351)

This commit is contained in:
Timon
2026-08-28 11:19:44 +02:00
committed by GitHub
parent c8f38059f8
commit c2afc07a04
11 changed files with 98 additions and 50 deletions

View File

@@ -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<FrontendMessage> {
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 },
});
}

View File

@@ -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::keyboard::{Key, ModifierKeys};
pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState as PointerState, EditorPosition as Position, MouseKeys, ScrollDelta};
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<String>,
}
#[derive(Clone, Copy)]
pub enum OpenFileDialogContext {
Open,

View File

@@ -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<FileFilter>,
},
TriggerImport {
filters: Vec<FileFilter>,
},
TriggerSaveDocument {
document_id: DocumentId,
name: String,
path: Option<PathBuf>,
folder: Option<PathBuf>,
filters: Vec<FileFilter>,
content: serde_bytes::ByteBuf,
},
TriggerSaveFile {
name: String,
folder: Option<PathBuf>,
filters: Vec<FileFilter>,
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,

View File

@@ -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))]
@@ -91,3 +111,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<String>,
}

View File

@@ -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};
@@ -1089,6 +1090,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
name,
path,
folder,
filters: vec![FileFilter {
name: "Graphite Document".into(),
extensions: vec![extension.into()],
}],
content: content.into(),
})
});

View File

@@ -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::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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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());

View File

@@ -730,17 +730,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![file_type.file_filter()];
match node_graph_output {
TaggedValue::RenderOutput(RenderOutput {
@@ -751,6 +747,7 @@ impl NodeGraphExecutor {
responses.add(FrontendMessage::TriggerSaveFile {
name,
folder,
filters,
content: svg.into_bytes().into(),
});
} else {
@@ -804,6 +801,7 @@ impl NodeGraphExecutor {
responses.add(FrontendMessage::TriggerSaveFile {
name,
folder,
filters,
content: encoded.into(),
});
}

View File

@@ -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);
});

View File

@@ -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,14 @@ 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 {
// 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 {
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(",");
}

View File

@@ -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);

View File

@@ -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)]