Add a dropdown for resource selection and adopt it in the 'Image' node (#4543)

* Add a dropdown for resource selection and adopt it in the Image node

* Route every image upload through a new ResourceUpload handler with desktop support and migrate wired 'Image' node inputs

* Show each resource's user count in the resource picker dropdown

* Send a picked resource file to the document that requested it, list every non-font resource in the picker, and link sibling message docs
This commit is contained in:
Keavon Chambers
2026-09-18 18:14:24 -07:00
committed by GitHub
parent cb30348215
commit f90bef159c
34 changed files with 774 additions and 180 deletions
+6
View File
@@ -103,6 +103,11 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
editor.importFile(data.filename, data.content);
});
subscriptions.subscribeFrontendMessage("TriggerUploadResource", async ({ filters }) => {
const data = await upload(acceptStringFromFilters(filters), "data");
editor.uploadResource(data.filename, data.content);
});
subscriptions.subscribeFrontendMessage("TriggerSaveDocument", (data) => {
downloadFile(data.name, data.content);
});
@@ -192,6 +197,7 @@ export function destroyPortfolioStore() {
subscriptions.unsubscribeFrontendMessage("TriggerFetchAndOpenDocument");
subscriptions.unsubscribeFrontendMessage("TriggerOpen");
subscriptions.unsubscribeFrontendMessage("TriggerImport");
subscriptions.unsubscribeFrontendMessage("TriggerUploadResource");
subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
+4
View File
@@ -85,9 +85,13 @@ export async function pasteFile(item: DataTransferItem, editor: EditorWrapper, m
const file = item.getAsFile();
if (!file) return;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (file.type.startsWith("image/svg")) {
const svg = await file.text();
editor.pasteSvg(file.name, svg, mouse?.[0], mouse?.[1], insertParentId, insertIndex);
} else if (editor.rasterImageExtensions().includes(extension)) {
// Formats the editor decodes itself keep their original bytes instead of being rasterized by the browser
editor.pasteImageFile(file.name, await file.bytes(), mouse?.[0], mouse?.[1], insertParentId, insertIndex);
} 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);
+32 -6
View File
@@ -20,10 +20,12 @@ mod editor_commands {
use editor::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
use editor::messages::portfolio::resource_upload::utility_types::UploadTarget;
use editor::messages::portfolio::utility_types::PanelGroupId;
use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use graphene_std::vector::style::FillChoice;
use std::path::PathBuf;
@@ -613,7 +615,7 @@ mod editor_commands {
.into()
}
/// Pastes an image
/// Pastes decoded RGBA8 pixels as an image layer, encoded as PNG for storage
fn paste_image(
name: Option<String>,
image_data: Vec<u8>,
@@ -625,7 +627,7 @@ mod editor_commands {
insert_index: Option<usize>,
) -> Message {
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
let image = graphene_std::raster::Image::from_image_data(&image_data, width, height);
let data = Image::from_image_data(&image_data, width, height).to_png();
let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) {
let insert_parent_id = NodeId(insert_parent_id);
@@ -635,15 +637,39 @@ mod editor_commands {
None
};
PortfolioMessage::InsertImage {
ResourceUploadMessage::Upload {
name,
image,
mouse,
parent_and_insert_index,
data: data.into(),
target: UploadTarget::Layer { mouse, parent_and_insert_index },
}
.into()
}
/// Pastes an image file as an image layer, keeping its original encoding
fn paste_image_file(name: Option<String>, data: Vec<u8>, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) -> Message {
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
let parent_and_insert_index = if let (Some(insert_parent_id), Some(insert_index)) = (insert_parent_id, insert_index) {
let insert_parent_id = NodeId(insert_parent_id);
let parent = LayerNodeIdentifier::new_unchecked(insert_parent_id);
Some((parent, insert_index))
} else {
None
};
ResourceUploadMessage::Upload {
name,
data: data.into(),
target: UploadTarget::Layer { mouse, parent_and_insert_index },
}
.into()
}
/// Hands the file picked for a requested resource upload to the editor
fn upload_resource(name: String, data: Vec<u8>) -> Message {
ResourceUploadMessage::ReceiveUpload { name: Some(name), data: data.into() }.into()
}
/// Pastes an SVG given its string representation
fn paste_svg(name: Option<String>, svg: String, mouse_x: Option<f64>, mouse_y: Option<f64>, insert_parent_id: Option<u64>, insert_index: Option<usize>) -> Message {
let mouse = mouse_x.and_then(|x| mouse_y.map(|y| (x, y)));
+16
View File
@@ -240,6 +240,22 @@ impl EditorWrapper {
cfg!(debug_assertions)
}
/// The file extensions of raster images the editor decodes itself (web only; on desktop, dropped files are imported natively and this is never called)
#[cfg(all(feature = "web", not(feature = "native")))]
#[wasm_bindgen(js_name = rasterImageExtensions)]
pub fn raster_image_extensions(&self) -> Vec<String> {
editor::messages::portfolio::resource_upload::utility_types::RASTER_IMAGE_EXTENSIONS
.iter()
.map(|extension| extension.to_string())
.collect()
}
#[cfg(feature = "native")]
#[wasm_bindgen(js_name = rasterImageExtensions)]
pub fn raster_image_extensions(&self) -> Vec<String> {
log::error!("rasterImageExtensions is unavailable on desktop, where dropped files are imported natively");
Vec::new()
}
/// 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)]