Introduce network request handler (#4194)

* Impl NetworkMessageHandler

* Repalce Frontend fetch like messages with NetworkMessage

* Reimpl resource loading with NetworkMessage

* Fix wasm

* dedublicate resource requests

* Embedd font resources created by migration

* Fixup

* Fix tests

* Fix font catalog not loading

* Cleanup

* Fix layouts not updating when font catalog is loaded

* Review

* Review

* Cleanup
This commit is contained in:
Timon
2026-06-04 13:27:14 +00:00
committed by Keavon Chambers
parent cd8ea1f554
commit 08c6d02e5b
23 changed files with 348 additions and 224 deletions

2
Cargo.lock generated
View File

@@ -2191,6 +2191,7 @@ dependencies = [
"num_enum",
"once_cell",
"preprocessor",
"reqwest",
"serde",
"serde_bytes",
"serde_json",
@@ -2198,6 +2199,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tsify",
"url",
"usvg",
"vello",
"wasm-bindgen",

View File

@@ -48,6 +48,8 @@ spin = { workspace = true }
image = { workspace = true }
color = { workspace = true }
zip = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
# Optional local dependencies
wgpu-executor = { workspace = true, optional = true }

View File

@@ -32,6 +32,7 @@ pub struct DispatcherMessageHandlers {
key_mapping_message_handler: KeyMappingMessageHandler,
layout_message_handler: LayoutMessageHandler,
menu_bar_message_handler: MenuBarMessageHandler,
network_message_handler: NetworkMessageHandler,
pub(crate) portfolio_message_handler: PortfolioMessageHandler,
preferences_message_handler: PreferencesMessageHandler,
pub(crate) resource_storage_message_handler: ResourceStorageMessageHandler,
@@ -79,8 +80,6 @@ const FRONTEND_UPDATE_MESSAGES: &[MessageDiscriminant] = &[
MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::Document(DocumentMessageDiscriminant::RenderScrollbars)),
MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateDocumentLayerStructure),
];
// FrontendMessages that should be sent immediately
const IMMEDIATE_FRONTEND_MESSAGES: &[FrontendMessageDiscriminant] = &[FrontendMessageDiscriminant::TriggerResolveResource, FrontendMessageDiscriminant::TriggerFontCatalogLoad];
const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
MessageDiscriminant::Broadcast(BroadcastMessageDiscriminant::TriggerEvent(EventMessageDiscriminant::AnimationFrame)),
MessageDiscriminant::Animation(AnimationMessageDiscriminant::IncrementFrameCounter),
@@ -206,14 +205,7 @@ impl Dispatcher {
self.message_handlers.dialog_message_handler.process_message(message, &mut queue, context);
}
Message::Frontend(message) => {
let decreminant = message.to_discriminant();
self.responses.push(message);
// Handle these message immediately by returning early
if IMMEDIATE_FRONTEND_MESSAGES.contains(&decreminant) {
self.cleanup_queues(false);
return;
}
}
Message::InputPreprocessor(message) => {
self.message_handlers.input_preprocessor_message_handler.process_message(
@@ -238,6 +230,9 @@ impl Dispatcher {
self.message_handlers.layout_message_handler.process_message(message, &mut queue, context);
}
Message::Network(message) => {
self.message_handlers.network_message_handler.process_message(message, &mut queue, NetworkMessageContext {});
}
Message::ResourceStorage(message) => {
self.message_handlers
.resource_storage_message_handler

View File

@@ -12,7 +12,6 @@ use crate::messages::portfolio::document::utility_types::wires::{WirePath, WireP
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::NodeId;
use graphene_std::color::SRGBA8;
use graphene_std::raster::Image;
@@ -111,14 +110,6 @@ pub enum FrontendMessage {
name: String,
filename: String,
},
TriggerFontCatalogLoad,
TriggerResolveResource {
#[serde(rename = "documentId")]
document_id: DocumentId,
#[serde(rename = "resourceId")]
resource_id: ResourceId,
url: String,
},
TriggerPersistenceReadState,
TriggerPersistenceReadDocument {
#[serde(rename = "documentId")]

View File

@@ -2,11 +2,15 @@ use std::future::{Future, IntoFuture};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use dyn_any::WasmNotSend;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use crate::messages::prelude::*;
#[cfg(not(target_family = "wasm"))]
type InnerMessageFuture = Pin<Box<dyn Future<Output = Message> + Send + 'static>>;
#[cfg(target_family = "wasm")]
type InnerMessageFuture = Pin<Box<dyn Future<Output = Message> + 'static>>;
/// Invoked by the spawner after a result is sent, to wake the platform event loop.
pub type Wake = Arc<dyn Fn() + Send + Sync>;
@@ -23,7 +27,7 @@ pub struct MessageFuture {
}
impl MessageFuture {
pub fn new(future: impl Future<Output = Message> + Send + 'static) -> Self {
pub fn new(future: impl Future<Output = Message> + WasmNotSend + 'static) -> Self {
Self {
inner: Arc::new(Mutex::new(Some(Box::pin(future)))),
}
@@ -51,7 +55,7 @@ impl From<MessageFuture> for Message {
impl<T> From<T> for Message
where
T: Future<Output = Message> + Send + 'static,
T: Future<Output = Message> + WasmNotSend + 'static,
{
fn from(future: T) -> Self {
MessageFuture::new(future).into()

View File

@@ -34,6 +34,8 @@ pub enum Message {
#[child]
MenuBar(MenuBarMessage),
#[child]
Network(NetworkMessage),
#[child]
Portfolio(PortfolioMessage),
#[child]
Preferences(PreferencesMessage),

View File

@@ -15,6 +15,7 @@ pub mod input_preprocessor;
pub mod layout;
pub mod menu_bar;
pub mod message;
pub mod network;
pub mod portfolio;
pub mod preferences;
pub mod prelude;

View File

@@ -0,0 +1,10 @@
mod network_message;
mod network_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use network_message::{NetworkMessage, NetworkMessageDiscriminant};
#[doc(inline)]
pub use network_message_handler::{NetworkMessageContext, NetworkMessageHandler};
#[doc(inline)]
pub use utility_types::Client;

View File

@@ -0,0 +1,49 @@
use std::pin::Pin;
use dyn_any::WasmNotSend;
use crate::messages::network::utility_types::Client;
use crate::messages::prelude::*;
#[impl_message(Message, Network)]
#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize)]
#[derivative(Debug, PartialEq)]
pub enum NetworkMessage {
Request {
#[serde(skip, default)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
request: Option<RequestFn>,
},
}
impl NetworkMessage {
pub fn request<F, Fut>(f: F) -> Self
where
F: FnOnce(Client) -> Fut + WasmNotSend + 'static,
Fut: Future<Output = Message> + WasmNotSend + 'static,
{
NetworkMessage::Request {
request: Some(Box::new(move |c| Box::pin(f(c)))),
}
}
}
#[cfg(not(target_family = "wasm"))]
type RequestFuture = Pin<Box<dyn Future<Output = Message> + Send>>;
#[cfg(target_family = "wasm")]
type RequestFuture = Pin<Box<dyn Future<Output = Message>>>;
#[cfg(not(target_family = "wasm"))]
type RequestFn = Box<dyn FnOnce(Client) -> RequestFuture + Send>;
#[cfg(target_family = "wasm")]
type RequestFn = Box<dyn FnOnce(Client) -> RequestFuture>;
impl Clone for NetworkMessage {
fn clone(&self) -> Self {
match self {
NetworkMessage::Request { .. } => {
log::error!("Cloning a NetworkMessage::Request is not supported");
NetworkMessage::Request { request: None }
}
}
}
}

View File

@@ -0,0 +1,27 @@
use crate::messages::network::utility_types::Client;
use crate::messages::prelude::*;
#[derive(ExtractField)]
pub struct NetworkMessageContext {}
#[derive(Debug, Default, ExtractField)]
pub struct NetworkMessageHandler {
client: Client,
}
#[message_handler_data]
impl MessageHandler<NetworkMessage, NetworkMessageContext> for NetworkMessageHandler {
fn process_message(&mut self, message: NetworkMessage, responses: &mut VecDeque<Message>, _context: NetworkMessageContext) {
match message {
NetworkMessage::Request { request } => {
if let Some(request) = request {
responses.add(request(self.client.clone()));
} else {
log::error!("received a empty NetworkMessage::Request");
}
}
}
}
advertise_actions!(NetworkMessageDiscriminant;
);
}

View File

@@ -0,0 +1,30 @@
use reqwest::IntoUrl;
#[derive(Debug, Clone)]
pub struct Client {
inner: Option<reqwest::Client>,
}
impl Default for Client {
fn default() -> Self {
Self {
#[cfg(not(target_family = "wasm"))]
inner: reqwest::Client::builder().timeout(std::time::Duration::from_secs(100)).build().ok(),
#[cfg(target_family = "wasm")]
inner: reqwest::Client::builder().build().ok(),
}
}
}
impl Client {
pub async fn fetch<U: IntoUrl>(&self, url: U) -> Option<Box<[u8]>> {
let Some(client) = &self.inner else {
log::error!("HTTP client failed to initialize, cannot fetch");
return None;
};
let response = client.get(url).send().await;
let response = response.and_then(|r| r.error_for_status()).map_err(|err| log::error!("failed to fetch: {err}")).ok()?;
let bytes = response.bytes().await.map_err(|err| log::error!("failed to read response body: {err}")).ok()?;
Some(bytes.to_vec().into_boxed_slice())
}
}

View File

@@ -1,5 +1,5 @@
use crate::messages::prelude::*;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::application_io::resource::{DataSource, ResourceHash, ResourceId};
use graphene_std::text::Font;
use std::sync::Arc;
@@ -8,7 +8,8 @@ use std::sync::Arc;
pub enum ResourceMessage {
StoreEmbedded { resource_id: ResourceId, data: Arc<[u8]> },
AddFont { resource_id: ResourceId, font: Font },
Resolve,
ResolveStep { resource_id: ResourceId },
Resolved { resource_id: ResourceId, data: Arc<[u8]> },
ResolveAll,
Resolve { resource_id: ResourceId },
Resolved { resource_id: ResourceId, source: DataSource, hash: ResourceHash },
ResolveFailed { resource_id: ResourceId },
}

View File

@@ -1,9 +1,12 @@
use crate::messages::portfolio::document::resource::utility_types::EmbeddedResources;
use crate::messages::network::Client;
use crate::messages::portfolio::{document::resource::utility_types::EmbeddedResources, fonts::utility_types::FontCatalog};
use crate::messages::prelude::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use graph_craft::application_io::resource::{DataSource, LoadResource, Resource, ResourceHash, ResourceId, ResourceRegistry};
use graphene_std::text::Font;
use std::sync::Arc;
use url::Url;
#[derive(ExtractField)]
pub struct ResourceMessageContext<'a> {
@@ -16,13 +19,7 @@ pub struct ResourceMessageHandler {
pub registry: ResourceRegistry,
pub embedded: EmbeddedResources,
#[serde(skip)]
pending_resolves: HashMap<ResourceId, Option<ResolveProgress>>,
}
#[derive(Debug, Clone, PartialEq)]
struct ResolveProgress {
index: usize,
source: DataSource,
pending_resolves: HashSet<ResourceId>,
}
#[message_handler_data]
@@ -36,7 +33,6 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
self.registry.push_source_back(&resource_id, DataSource::Embedded);
self.registry.resolve(&resource_id, hash);
responses.add(ResourceStorageMessage::Store { data });
responses.add(ResourceMessage::Resolve);
}
ResourceMessage::AddFont { resource_id, font } => {
let style = fonts.font_catalog.find_font_style_in_catalog(&font);
@@ -49,92 +45,142 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
style: Some(style_name),
},
);
responses.add(ResourceMessage::Resolve);
responses.add(ResourceMessage::Resolve { resource_id });
}
ResourceMessage::Resolve => {
ResourceMessage::ResolveAll => {
let unresolved_ids: Vec<ResourceId> = self.registry.unresolved().map(|info| info.id).collect();
for id in unresolved_ids {
if self.pending_resolves.contains_key(&id) {
if self.pending_resolves.contains(&id) {
continue;
}
self.pending_resolves.insert(id, None);
responses.add(ResourceMessage::ResolveStep { resource_id: id });
responses.add(ResourceMessage::Resolve { resource_id: id });
}
}
ResourceMessage::ResolveStep { resource_id } => {
let Some(progress) = self.pending_resolves.get_mut(&resource_id) else { return };
ResourceMessage::Resolve { resource_id } => {
if self.pending_resolves.contains(&resource_id) {
log::warn!("Already pending resolve for {resource_id}; skipping");
return;
}
let Some(info) = self.registry.info(&resource_id) else {
log::error!("ResolveStep for {resource_id}: no registry entry");
self.pending_resolves.remove(&resource_id);
log::error!("Resolve for {resource_id}: no registry entry");
return;
};
let index = if let Some(progress) = progress { progress.index + 1 } else { 0 };
let Some(source) = info.sources.get(index).cloned() else {
log::error!("ResolveStep for {resource_id}: no more sources to try");
self.pending_resolves.remove(&resource_id);
if info.hash.is_some() {
log::warn!("Resource {resource_id} already resolved");
return;
};
*progress = Some(ResolveProgress { index, source: source.clone() });
}
match source {
DataSource::Embedded => {
// Embedded resources are loaded on document load.
// If we get to this point, it means the resource was not embedded and we should try the next source.
responses.add(ResourceMessage::ResolveStep { resource_id });
self.pending_resolves.insert(resource_id);
let font_catalog = fonts.font_catalog.clone();
let sources = info
.sources
.iter()
.map(|source| match source {
DataSource::Font { family, style } => {
let font = match style {
Some(style) => Font::new(family.clone(), style.clone()),
None => Font::new_with_default_style(family.clone()),
};
let hash = fonts.cached_hash(&font);
(source.clone(), hash)
}
source => (source.clone(), None),
})
.collect::<Vec<(DataSource, Option<ResourceHash>)>>();
async fn resolve_to_message(document_id: DocumentId, resource_id: ResourceId, source: DataSource, url: Url, client: &Client) -> Option<Message> {
let result = client.fetch(url.clone()).await;
match result {
Some(data) => {
let hash = ResourceHash::from(data.as_ref());
Some(Message::Batched {
messages: Box::new([
PortfolioMessage::DocumentPassMessage {
document_id,
message: ResourceMessage::Resolved { resource_id, source, hash }.into(),
}
.into(),
ResourceStorageMessage::Store { data: Arc::from(data) }.into(),
]),
})
}
None => {
log::warn!("Failed to fetch resource {resource_id} from {url}");
None
}
}
DataSource::Url(url) => {
responses.add(FrontendMessage::TriggerResolveResource {
}
responses.add(NetworkMessage::request(async move |client| {
let mut loaded_catalog = None;
let mut response: Option<Message> = None;
for (source, hash) in sources {
if let Some(hash) = hash {
response = Some(ResourceMessage::Resolved { resource_id, source, hash }.into());
break;
}
match &source {
DataSource::Embedded => continue,
DataSource::Url(url) => {
response = resolve_to_message(document_id, resource_id, source.clone(), url.clone(), &client).await;
}
DataSource::Font { family, style } => {
let font = match style {
Some(style) => Font::new(family.clone(), style.clone()),
None => Font::new_with_default_style(family.clone()),
};
if font_catalog.is_empty() && loaded_catalog.as_ref().is_none() {
loaded_catalog = FontCatalog::load_from_api(&client).await;
}
let url = loaded_catalog.as_ref().and_then(|catalog| catalog.download_url(&font)).or_else(|| font_catalog.download_url(&font));
if let Some(url) = url {
let Ok(url) = Url::parse(&url) else {
log::warn!("Invalid URL {url} for font resource {resource_id}");
continue;
};
response = resolve_to_message(document_id, resource_id, source.clone(), url, &client).await;
} else {
log::warn!("No download URL found for font resource {resource_id}");
}
}
}
if response.is_some() {
break;
}
}
let mut response = response.unwrap_or_else(|| {
log::error!("Resolve for {resource_id}: all sources exhausted");
PortfolioMessage::DocumentPassMessage {
document_id,
resource_id,
url: url.to_string(),
});
}
DataSource::Font { family, style } => {
let font = match style {
Some(style) => Font::new(family, style),
None => Font::new_with_default_style(family),
message: ResourceMessage::ResolveFailed { resource_id }.into(),
}
.into()
});
if let Some(catalog) = loaded_catalog.take() {
response = Message::Batched {
messages: Box::new([response, FontsMessage::CatalogLoaded { catalog }.into()]),
};
if let Some(hash) = fonts.cached_hash(&font) {
self.registry.resolve(&resource_id, hash);
self.pending_resolves.remove(&resource_id);
responses.add(NodeGraphMessage::RunDocumentGraph);
return;
}
if let Some(url) = fonts.cached_url(&font) {
responses.add(FrontendMessage::TriggerResolveResource { document_id, resource_id, url });
return;
}
responses.add(FrontendMessage::TriggerFontCatalogLoad);
self.pending_resolves.remove(&resource_id);
}
}
response
}))
}
ResourceMessage::Resolved { resource_id, data } => {
let hash = ResourceHash::from(data.as_ref());
let Some(progress) = self.pending_resolves.remove(&resource_id).and_then(|p| p) else {
log::error!("Resolved message for {resource_id} with no pending resolve");
return;
};
let Some(info) = self.registry.info(&resource_id) else {
// ResourceId was removed from registry after resolve started.
// This can happen if the document was modified while resolves were in-flight.
// Likely safe to ignore for now.
// TODO: Consider adding cleaner cancelation for in-flight resolves.
return;
};
let Some(source) = info.sources.get(progress.index).cloned() else {
log::error!("Resolved message for {resource_id} with no current source");
return;
};
if progress.source != source {
log::error!("Resolved message for {resource_id} with mismatched source");
ResourceMessage::Resolved { resource_id, source, hash } => {
self.pending_resolves.remove(&resource_id);
if self.registry.info(&resource_id).is_none() {
// Resource was removed from registry after the fetch started.
return;
}
self.registry.resolve(&resource_id, hash);
responses.add(ResourceStorageMessage::Store { data });
if let DataSource::Font { family, style } = source {
let font = match style {
@@ -144,9 +190,11 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
responses.add(FontsMessage::ResourceResolved { font, hash });
}
responses.add(ResourceMessage::Resolve);
responses.add(NodeGraphMessage::RunDocumentGraph);
}
ResourceMessage::ResolveFailed { resource_id } => {
self.pending_resolves.remove(&resource_id);
}
}
}

View File

@@ -1694,6 +1694,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
if let Some(TaggedValue::Font(font)) = node.inputs.get(2).and_then(|input| input.as_value()) {
let resource_id = ResourceId::new();
document.resources.registry.push_source_back(&resource_id, DataSource::Embedded);
document.resources.registry.push_source_back(
&resource_id,
DataSource::Font {

View File

@@ -6,6 +6,7 @@ use graphene_std::text::Font;
#[impl_message(Message, PortfolioMessage, Fonts)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum FontsMessage {
LoadCatalog,
CatalogLoaded {
catalog: FontCatalog,
},

View File

@@ -3,6 +3,7 @@ use crate::messages::portfolio::fonts::utility_types::FontCatalog;
use crate::messages::prelude::*;
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
use graphene_std::text::Font;
use std::sync::Arc;
#[derive(ExtractField)]
pub struct FontsMessageContext<'a> {
@@ -11,7 +12,7 @@ pub struct FontsMessageContext<'a> {
#[derive(Debug, Default, ExtractField)]
pub struct FontsMessageHandler {
pub font_catalog: FontCatalog,
pub font_catalog: Arc<FontCatalog>,
font_hashes: HashMap<Font, ResourceHash>,
font_data: HashMap<ResourceHash, Resource>,
}
@@ -22,9 +23,20 @@ impl MessageHandler<FontsMessage, FontsMessageContext<'_>> for FontsMessageHandl
let FontsMessageContext { resource_storage } = context;
match message {
FontsMessage::LoadCatalog => {
responses.add(NetworkMessage::request(async move |client| {
let Some(catalog) = FontCatalog::load_from_api(&client).await else {
log::error!("failed to load font catalog");
return Message::NoOp;
};
FontsMessage::CatalogLoaded { catalog }.into()
}));
}
FontsMessage::CatalogLoaded { catalog } => {
self.font_catalog = catalog;
self.font_catalog = Arc::new(catalog);
responses.add(PortfolioMessage::ResolveResources);
responses.add(ToolMessage::RefreshToolOptions);
responses.add(PropertiesPanelMessage::Refresh);
}
FontsMessage::ResourceResolved { font, hash } => {
let font = self.normalize(font);
@@ -69,11 +81,6 @@ impl FontsMessageHandler {
self.font_hashes.get(&font).copied()
}
pub fn cached_url(&self, font: &Font) -> Option<String> {
let font = self.normalize(font.clone());
self.font_catalog.download_url(&font)
}
pub fn get_resource_or_queue_load(&self, font: &Font, responses: &mut VecDeque<Message>) -> Resource {
let font = self.normalize(font.clone());
if let Some(hash) = self.font_hashes.get(&font) {
@@ -104,9 +111,6 @@ impl FontsMessageHandler {
}
fn normalize(&self, font: Font) -> Font {
match self.font_catalog.find_font_style_in_catalog(&font) {
Some(style) => Font::new(font.font_family, style.to_named_style()),
None => font,
}
self.font_catalog.normalize(font)
}
}

View File

@@ -1,4 +1,57 @@
use crate::messages::network::Client;
use graphene_std::text::Font;
use std::collections::HashMap;
const FONT_LIST_API: &str = "https://api.graphite.art/font-list";
#[derive(serde::Deserialize)]
struct FontListApiResponse {
items: Vec<FontListApiFamily>,
}
#[derive(serde::Deserialize)]
struct FontListApiFamily {
family: String,
variants: Vec<String>,
files: HashMap<String, String>,
}
impl FontCatalog {
pub async fn load_from_api(client: &Client) -> Option<Self> {
let Some(bytes) = client.fetch(FONT_LIST_API).await else {
log::error!("failed to fetch font catalog from API");
return None;
};
let response: FontListApiResponse = match serde_json::from_slice(&bytes) {
Ok(response) => response,
Err(err) => {
log::error!("failed to parse font catalog response: {err}");
return None;
}
};
let families = response
.items
.into_iter()
.map(|family| {
let styles = family
.variants
.iter()
.filter_map(|variant| {
let weight = variant.chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse::<u32>().unwrap_or(400);
let italic = variant.ends_with("italic");
let url = family.files.get(variant)?.replacen("http://", "https://", 1);
Some(FontCatalogStyle { weight, italic, url })
})
.collect();
FontCatalogFamily { name: family.family, styles }
})
.collect();
Some(Self(families))
}
}
// TODO: Should this be a BTreeMap instead?
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -26,6 +79,13 @@ impl FontCatalog {
Some(catalog_family.closest_style(weight, italic).url.clone())
}
pub fn normalize(&self, font: Font) -> Font {
match self.find_font_style_in_catalog(&font) {
Some(style) => Font::new(font.font_family, style.to_named_style()),
None => font,
}
}
pub fn iter(&self) -> impl Iterator<Item = &FontCatalogFamily> {
self.0.iter()
}
@@ -41,7 +101,7 @@ impl From<Vec<FontCatalogFamily>> for FontCatalog {
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogFamily {
/// The font family name.

View File

@@ -399,7 +399,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let mut used_resources = HashSet::new();
for (id, info) in self.unloaded_documents.iter() {
log::info!("Checking resources for unloaded document {:?}: {:?}", info.name, info.resources);
if let Some(resources) = &info.resources {
used_resources.extend(resources.iter());
} else {
@@ -422,14 +421,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
PortfolioMessage::ResolveDocumentResources { document_id } => {
if self.fonts.font_catalog.is_empty() {
responses.add_front(FrontendMessage::TriggerFontCatalogLoad);
return;
}
responses.add(PortfolioMessage::DocumentPassMessage {
document_id,
message: DocumentMessage::Resource(ResourceMessage::Resolve),
message: DocumentMessage::Resource(ResourceMessage::ResolveAll),
});
}
PortfolioMessage::LoadPersistedState { state } => {

View File

@@ -22,6 +22,7 @@ pub use crate::messages::input_mapper::{InputMapperMessage, InputMapperMessageCo
pub use crate::messages::input_preprocessor::{InputPreprocessorMessage, InputPreprocessorMessageContext, InputPreprocessorMessageDiscriminant, InputPreprocessorMessageHandler};
pub use crate::messages::layout::{LayoutMessage, LayoutMessageDiscriminant, LayoutMessageHandler};
pub use crate::messages::menu_bar::{MenuBarMessage, MenuBarMessageDiscriminant, MenuBarMessageHandler};
pub use crate::messages::network::{NetworkMessage, NetworkMessageContext, NetworkMessageDiscriminant, NetworkMessageHandler};
pub use crate::messages::portfolio::document::data_panel::{DataPanelMessage, DataPanelMessageDiscriminant};
pub use crate::messages::portfolio::document::graph_operation::{GraphOperationMessage, GraphOperationMessageContext, GraphOperationMessageDiscriminant, GraphOperationMessageHandler};
pub use crate::messages::portfolio::document::navigation::{NavigationMessage, NavigationMessageContext, NavigationMessageDiscriminant, NavigationMessageHandler};

View File

@@ -290,6 +290,10 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
self.options.fill.fill_choice = Some(solid(context.global_tool_data.primary_color));
}
if context.fonts.font_catalog.is_empty() {
responses.add_front(FontsMessage::LoadCatalog);
}
let options = match message {
ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options,
ToolMessage::Text(TextToolMessage::SelectionChanged) => {
@@ -598,14 +602,10 @@ impl TextToolData {
fn check_click(document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque<Message>) -> Option<LayerNodeIdentifier> {
let mouse = DVec2::new(input.mouse.position.x, input.mouse.position.y);
let layers: Vec<LayerNodeIdentifier> = document.metadata().all_layers().filter(|&layer| document.metadata().is_text_layer(layer)).collect();
for layer in layers {
document.metadata().all_layers().filter(|&layer| document.metadata().is_text_layer(layer)).find(|&layer| {
let transformed_quad = document.metadata().transform_to_viewport(layer) * text_bounding_box(layer, document, fonts, responses);
if transformed_quad.contains(mouse) {
return Some(layer);
}
}
None
transformed_quad.contains(mouse)
})
}
fn get_snap_candidates(&mut self, document: &DocumentMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque<Message>) {

View File

@@ -2,7 +2,6 @@
import { onMount, onDestroy, setContext } from "svelte";
import MainWindow from "/src/components/window/MainWindow.svelte";
import { createClipboardManager, destroyClipboardManager } from "/src/managers/clipboard";
import { createFontsManager, destroyFontsManager } from "/src/managers/fonts";
import { createHyperlinkManager, destroyHyperlinkManager } from "/src/managers/hyperlink";
import { createInputManager, destroyInputManager } from "/src/managers/input";
import { createLocalizationManager, destroyLocalizationManager } from "/src/managers/localization";
@@ -43,7 +42,6 @@
createLocalizationManager(subscriptions, editor);
createPanicManager(subscriptions);
createPersistenceManager(subscriptions, editor, stores.portfolio);
createFontsManager(subscriptions, editor);
createInputManager(subscriptions, editor, stores.dialog, stores.portfolio, stores.document);
// Initialize certain setup tasks required by the editor backend to be ready for the user now that the frontend is ready.
@@ -71,7 +69,6 @@
destroyLocalizationManager();
destroyPanicManager();
destroyPersistenceManager();
destroyFontsManager();
destroyInputManager();
});
</script>

View File

@@ -1,75 +0,0 @@
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
type ApiResponse = { family: string; variants: string[]; files: Record<string, string> }[];
const FONT_LIST_API = "https://api.graphite.art/font-list";
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
let editorWrapper: EditorWrapper | undefined = undefined;
let abortController: AbortController | undefined = undefined;
export function createFontsManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper) {
destroyFontsManager();
subscriptionsRouter = subscriptions;
editorWrapper = editor;
abortController = new AbortController();
subscriptions.subscribeFrontendMessage("TriggerFontCatalogLoad", async () => {
try {
const response = await fetch(FONT_LIST_API, abortController ? { signal: abortController.signal } : undefined);
if (!response.ok) throw new Error(`Font catalog request failed with status ${response.status}`);
const fontListResponse: { items: ApiResponse } = await response.json();
const fontListData = fontListResponse.items;
const catalog = fontListData.map((font) => {
const styles = font.variants.map((variant) => {
const weight = variant === "regular" || variant === "italic" ? 400 : parseInt(variant, 10);
const italic = variant.endsWith("italic");
const url = font.files[variant].replace("http://", "https://");
return { weight, italic, url };
});
return { name: font.family, styles };
});
editor.onFontCatalogLoad(catalog);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
throw error;
}
});
// Generic URL resolver
// TODO(keavon): This is currently only used for fonts, but it could be used for other resources and thus should be moved to a more sesible location
subscriptions.subscribeFrontendMessage("TriggerResolveResource", async (data) => {
try {
if (!data.url) throw new Error("No URL provided for resource resolution");
const response = await fetch(data.url, abortController ? { signal: abortController.signal } : undefined);
if (!response.ok) throw new Error(`Resource request failed with status ${response.status}`);
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
editor.onResourceResolved(data.documentId, data.resourceId, bytes);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
// eslint-disable-next-line no-console
console.error("Failed to resolve resource:", error);
}
});
}
export function destroyFontsManager() {
const subscriptions = subscriptionsRouter;
if (!subscriptions) return;
abortController?.abort();
subscriptions.unsubscribeFrontendMessage("TriggerFontCatalogLoad");
subscriptions.unsubscribeFrontendMessage("TriggerResolveResource");
}
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
import.meta.hot?.accept((newModule) => {
if (subscriptionsRouter && editorWrapper) newModule?.createFontsManager(subscriptionsRouter, editorWrapper);
});

View File

@@ -20,7 +20,6 @@ use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseStat
use editor::messages::layout::utility_types::layout_widget::LayoutTarget;
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
use editor::messages::portfolio::fonts::utility_types::{FontCatalog, FontCatalogFamily};
use editor::messages::portfolio::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
@@ -648,26 +647,6 @@ impl EditorWrapper {
Ok(())
}
/// The font catalog has been loaded
#[wasm_bindgen(js_name = onFontCatalogLoad)]
pub fn on_font_catalog_load(&self, catalog: Vec<FontCatalogFamily>) {
self.dispatch(FontsMessage::CatalogLoaded { catalog: FontCatalog::from(catalog) });
}
/// A requested resource has been resolved by the frontend.
#[wasm_bindgen(js_name = onResourceResolved)]
pub fn on_resource_resolved(&self, document_id: u64, resource_id: u64, data: Vec<u8>) -> Result<(), JsValue> {
self.dispatch(PortfolioMessage::DocumentPassMessage {
document_id: DocumentId(document_id),
message: DocumentMessage::Resource(ResourceMessage::Resolved {
resource_id: resource_id.into(),
data: std::sync::Arc::from(data),
}),
});
Ok(())
}
/// Dialog got dismissed
#[wasm_bindgen(js_name = onDialogDismiss)]
pub fn on_dialog_dismiss(&self) {