From a3f8cd9c943b00e803b7aaa8b2c84720844fb815 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 15 Dec 2025 20:58:00 -0800 Subject: [PATCH 1/3] Remove FontInput.svelte --- .../messages/layout/layout_message_handler.rs | 38 ---- .../layout/utility_types/layout_widget.rs | 4 - .../utility_types/widgets/input_widgets.rs | 35 ---- .../document/document_message_handler.rs | 33 ---- frontend/src/components/panels/Layers.svelte | 2 +- .../src/components/widgets/WidgetSpan.svelte | 5 - .../widgets/inputs/FontInput.svelte | 186 ------------------ frontend/src/messages.ts | 20 -- 8 files changed, 1 insertion(+), 322 deletions(-) delete mode 100644 frontend/src/components/widgets/inputs/FontInput.svelte diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index d3d32abd9b..5a231ace5f 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -264,44 +264,6 @@ impl LayoutMessageHandler { responses.add(callback_message); } - Widget::FontInput(font_input) => { - let callback_message = match action { - WidgetValueAction::Commit => (font_input.on_commit.callback)(&()), - WidgetValueAction::Update => { - let Some(update_value) = value.as_object() else { - error!("FontInput update was not of type: object"); - return; - }; - let Some(font_family_value) = update_value.get("fontFamily") else { - error!("FontInput update does not have a fontFamily"); - return; - }; - let Some(font_style_value) = update_value.get("fontStyle") else { - error!("FontInput update does not have a fontStyle"); - return; - }; - - let Some(font_family) = font_family_value.as_str() else { - error!("FontInput update fontFamily was not of type: string"); - return; - }; - let Some(font_style) = font_style_value.as_str() else { - error!("FontInput update fontStyle was not of type: string"); - return; - }; - - font_input.font_family = font_family.into(); - font_input.font_style = font_style.into(); - - responses.add(PortfolioMessage::LoadFont { - font: Font::new(font_family.into(), font_style.into()), - }); - (font_input.on_update.callback)(font_input) - } - }; - - responses.add(callback_message); - } Widget::IconButton(icon_button) => { let callback_message = match action { WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()), diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 17c3a7acfb..fbde2ba03f 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -367,7 +367,6 @@ impl LayoutGroup { Widget::ColorInput(x) => &mut x.tooltip_label, Widget::CurveInput(x) => &mut x.tooltip_label, Widget::DropdownInput(x) => &mut x.tooltip_label, - Widget::FontInput(x) => &mut x.tooltip_label, Widget::IconButton(x) => &mut x.tooltip_label, Widget::IconLabel(x) => &mut x.tooltip_label, Widget::ImageButton(x) => &mut x.tooltip_label, @@ -403,7 +402,6 @@ impl LayoutGroup { Widget::ColorInput(x) => &mut x.tooltip_description, Widget::CurveInput(x) => &mut x.tooltip_description, Widget::DropdownInput(x) => &mut x.tooltip_description, - Widget::FontInput(x) => &mut x.tooltip_description, Widget::IconButton(x) => &mut x.tooltip_description, Widget::IconLabel(x) => &mut x.tooltip_description, Widget::ImageButton(x) => &mut x.tooltip_description, @@ -727,7 +725,6 @@ pub enum Widget { ColorInput(ColorInput), CurveInput(CurveInput), DropdownInput(DropdownInput), - FontInput(FontInput), IconButton(IconButton), IconLabel(IconLabel), ImageButton(ImageButton), @@ -782,7 +779,6 @@ impl DiffUpdate { Widget::CheckboxInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::ColorInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::DropdownInput(widget) => widget.tooltip_shortcut.as_mut(), - Widget::FontInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::IconButton(widget) => widget.tooltip_shortcut.as_mut(), Widget::NumberInput(widget) => widget.tooltip_shortcut.as_mut(), Widget::ParameterExposeButton(widget) => widget.tooltip_shortcut.as_mut(), diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index c1dfe3d50f..47a0733a8e 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -152,41 +152,6 @@ pub struct MenuListEntry { pub on_commit: WidgetCallback<()>, } -#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)] -#[derivative(Debug, PartialEq, Default)] -pub struct FontInput { - #[serde(rename = "fontFamily")] - #[widget_builder(constructor)] - pub font_family: String, - - #[serde(rename = "fontStyle")] - #[widget_builder(constructor)] - pub font_style: String, - - #[serde(rename = "isStyle")] - pub is_style_picker: bool, - - pub disabled: bool, - - #[serde(rename = "tooltipLabel")] - pub tooltip_label: String, - - #[serde(rename = "tooltipDescription")] - pub tooltip_description: String, - - #[serde(rename = "tooltipShortcut")] - pub tooltip_shortcut: Option, - - // Callbacks - #[serde(skip)] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_update: WidgetCallback, - - #[serde(skip)] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_commit: WidgetCallback<()>, -} - #[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)] #[derivative(Debug, PartialEq, Default)] pub struct NumberInput { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 9898a95b93..0e3511dbd9 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -2186,39 +2186,6 @@ impl DocumentMessageHandler { } pub fn update_document_widgets(&self, responses: &mut VecDeque, animation_is_playing: bool, time: Duration) { - // // Document mode (dropdown menu at the left of the bar above the viewport, before the tool options) - // let layout = Layout(vec![LayoutGroup::Row { - // widgets: vec![ - // DropdownInput::new( - // vec![vec![ - // MenuListEntry::new(format!("{:?}", DocumentMode::DesignMode)) - // .label(DocumentMode::DesignMode.to_string()) - // .icon(DocumentMode::DesignMode.icon_name()), - // // TODO: See issue #330 - // MenuListEntry::new(format!("{:?}", DocumentMode::SelectMode)) - // .label(DocumentMode::SelectMode.to_string()) - // .icon(DocumentMode::SelectMode.icon_name()) - // .on_commit(|_| todo!()), - // // TODO: See issue #331 - // MenuListEntry::new(format!("{:?}", DocumentMode::GuideMode)) - // .label(DocumentMode::GuideMode.to_string()) - // .icon(DocumentMode::GuideMode.icon_name()) - // .on_commit(|_| todo!()), - // ]]) - // .selected_index(Some(self.document_mode as u32)) - // .draw_icon(true) - // .interactive(false) - // .widget_instance(), - // Separator::new(SeparatorType::Section).widget_instance(), - // ], - // }]); - // responses.add(LayoutMessage::SendLayout { - // layout, - // layout_target: LayoutTarget::DocumentMode, - // }); - - // Document bar (right portion of the bar above the viewport) - let mut snapping_state = self.snapping_state.clone(); let mut snapping_state2 = self.snapping_state.clone(); diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 732df7cb4a..cad9f861d4 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -627,7 +627,7 @@ data-tooltip-description={(listing.entry.expanded ? "Hide the layers nested within. (To affect all open descendants, perform the shortcut shown.)" : "Show the layers nested within. (To affect all closed descendants, perform the shortcut shown.)") + - (listing.entry.ancestorOfSelected && !listing.entry.expanded ? "\n\nNote: a selected layer is currently contained within.\n" : "")} + (listing.entry.ancestorOfSelected && !listing.entry.expanded ? "\n\nA selected layer is currently contained within.\n" : "")} data-tooltip-shortcut={altClickShortcut?.shortcut ? JSON.stringify(altClickShortcut.shortcut) : undefined} on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)} tabindex="0" diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index a46a63fe40..8658128bcc 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -17,7 +17,6 @@ import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte"; import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte"; import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte"; - import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte"; import NumberInput from "@graphite/components/widgets/inputs/NumberInput.svelte"; import RadioInput from "@graphite/components/widgets/inputs/RadioInput.svelte"; import ReferencePointInput from "@graphite/components/widgets/inputs/ReferencePointInput.svelte"; @@ -111,10 +110,6 @@ on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(index, detail)} /> {/if} - {@const fontInput = narrowWidgetProps(component.props, "FontInput")} - {#if fontInput} - widgetValueCommitAndUpdate(index, detail)} /> - {/if} {@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")} {#if parameterExposeButton} widgetValueCommitAndUpdate(index, undefined)} /> diff --git a/frontend/src/components/widgets/inputs/FontInput.svelte b/frontend/src/components/widgets/inputs/FontInput.svelte deleted file mode 100644 index e8b2904615..0000000000 --- a/frontend/src/components/widgets/inputs/FontInput.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - - - - 0 ? { "min-width": `${minWidth}px` } : {}) }} - {tooltipLabel} - {tooltipDescription} - {tooltipShortcut} - tabindex={disabled ? -1 : 0} - on:click={toggleOpen} - data-floating-menu-spawner - > - {activeEntry?.value || ""} - - - isStyle && (minWidth = detail)} - {activeEntry} - on:activeEntry={({ detail }) => (activeEntry = detail)} - {open} - on:open={({ detail }) => (open = detail)} - entries={[entries]} - minWidth={isStyle ? 0 : minWidth} - virtualScrollingEntryHeight={isStyle ? 0 : 20} - scrollableY={true} - bind:this={menuList} - /> - - - diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 6705511823..3ff199c839 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -1062,25 +1062,6 @@ export class DropdownInput extends WidgetProps { maxWidth!: number; } -export class FontInput extends WidgetProps { - fontFamily!: string; - - fontStyle!: string; - - isStyle!: boolean; - - disabled!: boolean; - - @Transform(({ value }: { value: string }) => value || undefined) - tooltipLabel!: string | undefined; - - @Transform(({ value }: { value: string }) => value || undefined) - tooltipDescription!: string | undefined; - - @Transform(({ value }: { value: ActionShortcut }) => value || undefined) - tooltipShortcut!: ActionShortcut | undefined; -} - export class IconButton extends WidgetProps { icon!: IconName; @@ -1449,7 +1430,6 @@ const widgetSubTypes = [ { value: ColorInput, name: "ColorInput" }, { value: CurveInput, name: "CurveInput" }, { value: DropdownInput, name: "DropdownInput" }, - { value: FontInput, name: "FontInput" }, { value: IconButton, name: "IconButton" }, { value: ImageButton, name: "ImageButton" }, { value: ImageLabel, name: "ImageLabel" }, From 46ec336bb17bd1bbcf5f6c195295297371271f78 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 17 Dec 2025 05:12:03 -0800 Subject: [PATCH 2/3] Move font picking to the backend --- editor/src/dispatcher.rs | 8 +- .../src/messages/frontend/frontend_message.rs | 11 +- .../messages/layout/layout_message_handler.rs | 2 - .../layout/utility_types/layout_widget.rs | 78 ++++---- .../utility_types/widgets/button_widgets.rs | 4 + .../utility_types/widgets/input_widgets.rs | 20 ++ .../document/document_message_handler.rs | 23 ++- .../document/node_graph/node_properties.rs | 142 ++++++++++++-- .../document/overlays/utility_functions.rs | 2 +- .../messages/portfolio/portfolio_message.rs | 11 +- .../portfolio/portfolio_message_handler.rs | 58 ++++-- .../src/messages/portfolio/utility_types.rs | 70 ++++++- .../src/messages/tool/tool_message_handler.rs | 12 +- .../tool/tool_messages/select_tool.rs | 4 +- .../messages/tool/tool_messages/text_tool.rs | 178 ++++++++++++++---- editor/src/messages/tool/utility_types.rs | 18 +- frontend/src/components/Editor.svelte | 5 +- .../components/floating-menus/MenuList.svelte | 69 ++++--- .../src/components/panels/Document.svelte | 20 +- .../src/components/widgets/WidgetSpan.svelte | 66 ++++--- .../widgets/buttons/TextButton.svelte | 2 + .../widgets/inputs/DropdownInput.svelte | 25 ++- frontend/src/io-managers/fonts.ts | 44 +++++ frontend/src/messages.ts | 25 ++- frontend/src/state-providers/fonts.ts | 115 ----------- frontend/wasm/src/editor_api.rs | 61 +++--- node-graph/libraries/core-types/src/consts.rs | 2 +- node-graph/nodes/gstd/src/text.rs | 4 +- node-graph/nodes/text/src/font_cache.rs | 89 ++++++--- 29 files changed, 793 insertions(+), 375 deletions(-) create mode 100644 frontend/src/io-managers/fonts.ts delete mode 100644 frontend/src/state-providers/fonts.ts diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 3e4b575b95..c3e90c9504 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -51,7 +51,7 @@ const SIDE_EFFECT_FREE_MESSAGES: &[MessageDiscriminant] = &[ NodeGraphMessageDiscriminant::RunDocumentGraph, ))), MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::SubmitActiveGraphRender), - MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontLoad), + MessageDiscriminant::Frontend(FrontendMessageDiscriminant::TriggerFontDataLoad), MessageDiscriminant::Frontend(FrontendMessageDiscriminant::UpdateUIScale), ]; /// Since we don't need to update the frontend multiple times per frame, @@ -69,6 +69,7 @@ const FRONTEND_UPDATE_MESSAGES: &[MessageDiscriminant] = &[ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[ MessageDiscriminant::Broadcast(BroadcastMessageDiscriminant::TriggerEvent(EventMessageDiscriminant::AnimationFrame)), MessageDiscriminant::Animation(AnimationMessageDiscriminant::IncrementFrameCounter), + MessageDiscriminant::Portfolio(PortfolioMessageDiscriminant::AutoSaveAllDocuments), ]; // TODO: Find a way to combine these with the list above. We use strings for now since these are the standard variant names used by multiple messages. But having these also type-checked would be best. const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"]; @@ -179,7 +180,7 @@ impl Dispatcher { } Message::Frontend(message) => { // Handle these messages immediately by returning early - if let FrontendMessage::TriggerFontLoad { .. } = message { + if let FrontendMessage::TriggerFontDataLoad { .. } | FrontendMessage::TriggerFontCatalogLoad = message { self.responses.push(message); self.cleanup_queues(false); @@ -359,8 +360,9 @@ impl Dispatcher { fn log_message(&self, message: &Message, queues: &[VecDeque], message_logging_verbosity: MessageLoggingVerbosity) { let discriminant = MessageDiscriminant::from(message); let is_blocked = DEBUG_MESSAGE_BLOCK_LIST.contains(&discriminant) || DEBUG_MESSAGE_ENDING_BLOCK_LIST.iter().any(|blocked_name| discriminant.local_name().ends_with(blocked_name)); + let is_empty_batched = if let Message::Batched { messages } = message { messages.is_empty() } else { false }; - if !is_blocked { + if !is_blocked && !is_empty_batched { match message_logging_verbosity { MessageLoggingVerbosity::Off => {} MessageLoggingVerbosity::Names => { diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 202d7e2399..ae6b4ff27b 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -39,7 +39,8 @@ pub enum FrontendMessage { #[serde(rename = "fontSize")] font_size: f64, color: Color, - url: String, + #[serde(rename = "fontData")] + font_data: Vec, transform: [f64; 6], #[serde(rename = "maxWidth")] max_width: Option, @@ -47,6 +48,10 @@ pub enum FrontendMessage { max_height: Option, align: TextAlign, }, + DisplayEditableTextboxUpdateFontData { + #[serde(rename = "fontData")] + font_data: Vec, + }, DisplayEditableTextboxTransform { transform: [f64; 6], }, @@ -92,8 +97,10 @@ pub enum FrontendMessage { name: String, filename: String, }, - TriggerFontLoad { + TriggerFontCatalogLoad, + TriggerFontDataLoad { font: Font, + url: String, }, TriggerImport, TriggerPersistenceRemoveDocument { diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 5a231ace5f..2f34cbd9b8 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -2,7 +2,6 @@ use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; use graphene_std::raster::color::Color; -use graphene_std::text::Font; use graphene_std::vector::style::{FillChoice, GradientStops}; use serde_json::Value; use std::collections::HashMap; @@ -48,7 +47,6 @@ impl MessageHandler> for LayoutMessageHa } LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => { self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Update, responses); - responses.add(LayoutMessage::ResendActiveWidget { layout_target, widget_id }); } } } diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index fbde2ba03f..6224ea4f07 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -353,42 +353,7 @@ impl From> for LayoutGroup { } impl LayoutGroup { - /// Applies a tooltip label to all widgets in this row or column without a tooltip. - pub fn with_tooltip_label(self, label: impl Into) -> Self { - let (is_col, mut widgets) = match self { - LayoutGroup::Column { widgets } => (true, widgets), - LayoutGroup::Row { widgets } => (false, widgets), - _ => unimplemented!(), - }; - let label = label.into(); - for widget in &mut widgets { - let val = match &mut widget.widget { - Widget::CheckboxInput(x) => &mut x.tooltip_label, - Widget::ColorInput(x) => &mut x.tooltip_label, - Widget::CurveInput(x) => &mut x.tooltip_label, - Widget::DropdownInput(x) => &mut x.tooltip_label, - Widget::IconButton(x) => &mut x.tooltip_label, - Widget::IconLabel(x) => &mut x.tooltip_label, - Widget::ImageButton(x) => &mut x.tooltip_label, - Widget::ImageLabel(x) => &mut x.tooltip_label, - Widget::NumberInput(x) => &mut x.tooltip_label, - Widget::ParameterExposeButton(x) => &mut x.tooltip_label, - Widget::PopoverButton(x) => &mut x.tooltip_label, - Widget::TextAreaInput(x) => &mut x.tooltip_label, - Widget::TextButton(x) => &mut x.tooltip_label, - Widget::TextInput(x) => &mut x.tooltip_label, - Widget::TextLabel(x) => &mut x.tooltip_label, - Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip_label, - Widget::ReferencePointInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::ShortcutLabel(_) | Widget::WorkingColorsInput(_) | Widget::NodeCatalog(_) => continue, - }; - if val.is_empty() { - val.clone_from(&label); - } - } - if is_col { Self::Column { widgets } } else { Self::Row { widgets } } - } - - /// Applies a tooltip description to all widgets in this row or column without a tooltip. + /// Applies a tooltip description to all widgets without a tooltip in this row or column. pub fn with_tooltip_description(self, description: impl Into) -> Self { let (is_col, mut widgets) = match self { LayoutGroup::Column { widgets } => (true, widgets), @@ -407,14 +372,19 @@ impl LayoutGroup { Widget::ImageButton(x) => &mut x.tooltip_description, Widget::ImageLabel(x) => &mut x.tooltip_description, Widget::NumberInput(x) => &mut x.tooltip_description, - Widget::ParameterExposeButton(x) => &mut x.tooltip_description, Widget::PopoverButton(x) => &mut x.tooltip_description, Widget::TextAreaInput(x) => &mut x.tooltip_description, Widget::TextButton(x) => &mut x.tooltip_description, Widget::TextInput(x) => &mut x.tooltip_description, Widget::TextLabel(x) => &mut x.tooltip_description, Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip_description, - Widget::ReferencePointInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::ShortcutLabel(_) | Widget::WorkingColorsInput(_) | Widget::NodeCatalog(_) => continue, + Widget::ReferencePointInput(_) + | Widget::RadioInput(_) + | Widget::Separator(_) + | Widget::ShortcutLabel(_) + | Widget::WorkingColorsInput(_) + | Widget::NodeCatalog(_) + | Widget::ParameterExposeButton(_) => continue, }; if val.is_empty() { val.clone_from(&description); @@ -834,10 +804,38 @@ impl DiffUpdate { (recursive_wrapper.0)(entry_sections, &recursive_wrapper) }; + // Hash the menu list entry sections for caching purposes + let hash_menu_list_entry_sections = |entry_sections: &MenuListEntrySections| { + struct RecursiveHasher<'a> { + hasher: DefaultHasher, + hash_fn: &'a dyn Fn(&mut RecursiveHasher, &MenuListEntrySections), + } + let mut recursive_hasher = RecursiveHasher { + hasher: DefaultHasher::new(), + hash_fn: &|recursive_hasher, entry_sections| { + for (index, entries) in entry_sections.iter().enumerate() { + index.hash(&mut recursive_hasher.hasher); + for entry in entries { + entry.hash(&mut recursive_hasher.hasher); + (recursive_hasher.hash_fn)(recursive_hasher, &entry.children); + } + } + }, + }; + (recursive_hasher.hash_fn)(&mut recursive_hasher, entry_sections); + recursive_hasher.hasher.finish() + }; + // Apply shortcut conversions to all widgets that have menu lists let convert_menu_lists = |widget_instance: &mut WidgetInstance| match &mut widget_instance.widget { - Widget::DropdownInput(dropdown_input) => apply_action_shortcut_to_menu_lists(&mut dropdown_input.entries), - Widget::TextButton(text_button) => apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children), + Widget::DropdownInput(dropdown_input) => { + apply_action_shortcut_to_menu_lists(&mut dropdown_input.entries); + dropdown_input.entries_hash = hash_menu_list_entry_sections(&dropdown_input.entries); + } + Widget::TextButton(text_button) => { + apply_action_shortcut_to_menu_lists(&mut text_button.menu_list_children); + text_button.menu_list_children_hash = hash_menu_list_entry_sections(&text_button.menu_list_children); + } _ => {} }; diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index 3f8f59b8a1..e1b48940f6 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -144,6 +144,10 @@ pub struct TextButton { #[serde(rename = "menuListChildren")] pub menu_list_children: MenuListEntrySections, + #[serde(rename = "menuListChildrenHash")] + #[widget_builder(skip)] + pub menu_list_children_hash: u64, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 47a0733a8e..75ae5fafc3 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -80,6 +80,10 @@ pub struct DropdownInput { #[widget_builder(constructor)] pub entries: MenuListEntrySections, + #[serde(rename = "entriesHash")] + #[widget_builder(skip)] + pub entries_hash: u64, + // This uses `u32` instead of `usize` since it will be serialized as a normal JS number (replace this with `usize` after switching to a Rust-based GUI) #[serde(rename = "selectedIndex")] pub selected_index: Option, @@ -94,6 +98,9 @@ pub struct DropdownInput { pub narrow: bool, + #[serde(rename = "virtualScrolling")] + pub virtual_scrolling: bool, + #[serde(rename = "tooltipLabel")] pub tooltip_label: String, @@ -142,6 +149,10 @@ pub struct MenuListEntry { pub children: MenuListEntrySections, + #[serde(rename = "childrenHash")] + #[widget_builder(skip)] + pub children_hash: u64, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] @@ -152,6 +163,15 @@ pub struct MenuListEntry { pub on_commit: WidgetCallback<()>, } +impl std::hash::Hash for MenuListEntry { + fn hash(&self, state: &mut H) { + self.value.hash(state); + self.label.hash(state); + self.icon.hash(state); + self.disabled.hash(state); + } +} + #[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)] #[derivative(Debug, PartialEq, Default)] pub struct NumberInput { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 0e3511dbd9..a0b7332c25 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -19,8 +19,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::{Doc use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate}; use crate::messages::portfolio::document::utility_types::nodes::RawBuffer; -use crate::messages::portfolio::utility_types::PanelType; -use crate::messages::portfolio::utility_types::PersistentData; +use crate::messages::portfolio::utility_types::{FontCatalog, PanelType, PersistentData}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity}; use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys; @@ -36,6 +35,7 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Raster; use graphene_std::subpath::Subpath; use graphene_std::table::Table; +use graphene_std::text::Font; use graphene_std::vector::PointId; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; @@ -2171,17 +2171,24 @@ impl DocumentMessageHandler { } /// Loads all of the fonts in the document. - pub fn load_layer_resources(&self, responses: &mut VecDeque) { - let mut fonts = HashSet::new(); - for (_node_id, node, _) in self.document_network().recursive_nodes() { + pub fn load_layer_resources(&self, responses: &mut VecDeque, font_catalog: &FontCatalog) { + let mut fonts_to_load = HashSet::new(); + + for (_, node, _) in self.document_network().recursive_nodes() { for input in &node.inputs { if let Some(TaggedValue::Font(font)) = input.as_value() { - fonts.insert(font.clone()); + fonts_to_load.insert(font.clone()); } } } - for font in fonts { - responses.add_front(FrontendMessage::TriggerFontLoad { font }); + + for font in fonts_to_load { + if let Some(style) = font_catalog.find_font_style_in_catalog(&font) { + responses.add_front(FrontendMessage::TriggerFontDataLoad { + font: Font::new(font.font_family, style.to_named_style()), + url: style.url, + }); + } } } diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 10938b463f..71e8e00089 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -4,6 +4,7 @@ use super::document_node_definitions::{NODE_OVERRIDES, NodePropertiesContext}; use super::utility_types::FrontendGraphDataType; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::portfolio::utility_types::{FontCatalogStyle, PersistentData}; use crate::messages::prelude::*; use choice::enum_choice; use dyn_any::DynAny; @@ -34,7 +35,7 @@ pub(crate) fn string_properties(text: &str) -> Vec { fn optionally_update_value(value: impl Fn(&T) -> Option + 'static + Send + Sync, node_id: NodeId, input_index: usize) -> impl Fn(&T) -> Message + 'static + Send + Sync { move |input_value: &T| match value(input_value) { Some(value) => NodeGraphMessage::SetInputValue { node_id, input_index, value }.into(), - _ => Message::NoOp, + None => Message::NoOp, } } @@ -86,6 +87,7 @@ pub fn start_widgets(parameter_widgets_info: ParameterWidgetsInfo) -> Vec (Vec, Option>) { - let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + let ParameterWidgetsInfo { + persistent_data, + document_node, + node_id, + index, + .. + } = parameter_widgets_info; let mut first_widgets = start_widgets(parameter_widgets_info); let mut second_widgets = None; - let from_font_input = |font: &FontInput| TaggedValue::Font(Font::new(font.font_family.clone(), font.font_style.clone())); - let Some(document_node) = document_node else { return (Vec::new(), None) }; let Some(input) = document_node.inputs.get(index) else { log::warn!("A widget failed to be built because its node's input index is invalid."); return (vec![], None); }; + if let Some(TaggedValue::Font(font)) = &input.as_non_exposed_value() { first_widgets.extend_from_slice(&[ Separator::new(SeparatorType::Unrelated).widget_instance(), - FontInput::new(font.font_family.clone(), font.font_style.clone()) - .on_update(update_value(from_font_input, node_id, index)) - .on_commit(commit_value) - .widget_instance(), + DropdownInput::new(vec![ + persistent_data + .font_catalog + .0 + .iter() + .map(|family| { + MenuListEntry::new(family.name.clone()) + .label(family.name.clone()) + .font({ + // Get the URL for the stylesheet of a subsetted font preview for the font style closest to weight 400 + let preview_name = family.name.replace(' ', "+"); + let preview_weight = family.closest_style(400, false).weight; + format!("https://fonts.googleapis.com/css2?display=swap&family={preview_name}:wght@{preview_weight}&text={preview_name}") + }) + .on_update({ + // Construct the new font using the new family and the initial or previous style, although this style might not exist in the catalog + let mut new_font = Font::new(family.name.clone(), font.font_style_to_restore.clone().unwrap_or_else(|| font.font_style.clone())); + new_font.font_style_to_restore = font.font_style_to_restore.clone(); + + // If not already, store the initial style so it can be restored if the user switches to another family + if new_font.font_style_to_restore.is_none() { + new_font.font_style_to_restore = Some(new_font.font_style.clone()); + } + + // Use the closest style available in the family for the new font to ensure the style exists + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&new_font.font_style, ""); + new_font.font_style = family.closest_style(weight, italic).to_named_style(); + + move |_| { + let new_font = new_font.clone(); + + Message::Batched { + messages: Box::new([ + PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), + update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), + ]), + } + } + }) + .on_commit({ + // Use the new value from the user selection + let font_family = family.name.clone(); + + // Use the previous style selection and extract its weight and italic properties, then find the closest style in the new family + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, ""); + let font_style = family.closest_style(weight, italic).to_named_style(); + + move |_| { + let new_font = Font::new(font_family.clone(), font_style.clone()); + + DeferMessage::AfterGraphRun { + messages: vec![update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), commit_value(&())], + } + .into() + } + }) + }) + .collect::>(), + ]) + .selected_index(persistent_data.font_catalog.0.iter().position(|family| family.name == font.font_family).map(|i| i as u32)) + .virtual_scrolling(true) + .widget_instance(), ]); let mut second_row = vec![TextLabel::new("").widget_instance()]; add_blank_assist(&mut second_row); second_row.extend_from_slice(&[ Separator::new(SeparatorType::Unrelated).widget_instance(), - FontInput::new(font.font_family.clone(), font.font_style.clone()) - .is_style_picker(true) - .on_update(update_value(from_font_input, node_id, index)) - .on_commit(commit_value) - .widget_instance(), + DropdownInput::new({ + persistent_data + .font_catalog + .0 + .iter() + .find(|family| family.name == font.font_family) + .map(|family| { + let build_entry = |style: &FontCatalogStyle| { + let font_style = style.to_named_style(); + MenuListEntry::new(font_style.clone()) + .label(font_style.clone()) + .on_update({ + let font_family = font.font_family.clone(); + let font_style = font_style.clone(); + + move |_| { + // Keep the existing family + let new_font = Font::new(font_family.clone(), font_style.clone()); + + Message::Batched { + messages: Box::new([ + PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), + update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), + ]), + } + } + }) + .on_commit(commit_value) + }; + + vec![ + family.styles.iter().filter(|style| !style.italic).map(build_entry).collect::>(), + family.styles.iter().filter(|style| style.italic).map(build_entry).collect::>(), + ] + }) + .filter(|styles| !styles.is_empty()) + .unwrap_or_default() + }) + .selected_index( + persistent_data + .font_catalog + .0 + .iter() + .find(|family| family.name == font.font_family) + .and_then(|family| { + let not_italic = family.styles.iter().filter(|style| !style.italic); + let italic = family.styles.iter().filter(|style| style.italic); + not_italic.chain(italic).position(|style| style.to_named_style() == font.font_style) + }) + .map(|i| i as u32), + ) + .widget_instance(), ]); second_widgets = Some(second_row); } @@ -2033,6 +2145,7 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> } pub struct ParameterWidgetsInfo<'a> { + persistent_data: &'a PersistentData, document_node: Option<&'a DocumentNode>, node_id: NodeId, index: usize, @@ -2053,6 +2166,7 @@ impl<'a> ParameterWidgetsInfo<'a> { let document_node = context.network_interface.document_node(&node_id, context.selection_network_path); ParameterWidgetsInfo { + persistent_data: context.persistent_data, document_node, node_id, index, @@ -2227,7 +2341,7 @@ pub mod choice { let mut row = LayoutGroup::Row { widgets }; if let Some(desc) = self.widget_factory.description() { - row = row.with_tooltip_label(desc); + row = row.with_tooltip_description(desc); } row } diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index a666cfe2c3..eb2877b5d7 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -227,7 +227,7 @@ pub static GLOBAL_FONT_CACHE: LazyLock = LazyLock::new(|| { // Initialize with the hardcoded font used by overlay text const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string()); - font_cache.insert(font, String::new(), FONT_DATA.to_vec()); + font_cache.insert(font, FONT_DATA.to_vec()); font_cache }); diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 42d9db99df..b601e3f558 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -2,6 +2,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::utility_types::PanelType; use crate::messages::frontend::utility_types::{ExportBounds, FileType}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; +use crate::messages::portfolio::utility_types::FontCatalog; use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::raster::Image; @@ -46,19 +47,21 @@ pub enum PortfolioMessage { }, DestroyAllDocuments, EditorPreferences, + FontCatalogLoaded { + catalog: FontCatalog, + }, + LoadFontData { + font: Font, + }, FontLoaded { font_family: String, font_style: String, - preview_url: String, data: Vec, }, Import, LoadDocumentResources { document_id: DocumentId, }, - LoadFont { - font: Font, - }, NewDocumentWithName { name: String, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 50ca0844e2..1d5fb9dee9 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -349,16 +349,31 @@ impl MessageHandler> for Portfolio self.active_document_id = None; responses.add(MenuBarMessage::SendLayout); } - PortfolioMessage::FontLoaded { - font_family, - font_style, - preview_url, - data, - } => { - let font = Font::new(font_family, font_style); + PortfolioMessage::FontCatalogLoaded { catalog } => { + self.persistent_data.font_catalog = catalog; + + if let Some(document_id) = self.active_document_id { + responses.add(PortfolioMessage::LoadDocumentResources { document_id }); + } - self.persistent_data.font_cache.insert(font, preview_url, data); + // Load the default font + let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()); + responses.add(PortfolioMessage::LoadFontData { font }); + } + PortfolioMessage::LoadFontData { font } => { + if let Some(style) = self.persistent_data.font_catalog.find_font_style_in_catalog(&font) { + let font = Font::new(font.font_family, style.to_named_style()); + + if !self.persistent_data.font_cache.loaded_font(&font) { + responses.add(FrontendMessage::TriggerFontDataLoad { font, url: style.url }); + } + } + } + PortfolioMessage::FontLoaded { font_family, font_style, data } => { + let font = Font::new(font_family, font_style); + self.persistent_data.font_cache.insert(font, data); self.executor.update_font_cache(self.persistent_data.font_cache.clone()); + for document_id in self.document_ids.iter() { let node_to_inspect = self.node_to_inspect(); @@ -382,6 +397,10 @@ impl MessageHandler> for Portfolio if self.active_document_mut().is_some() { responses.add(NodeGraphMessage::RunDocumentGraph); } + + if current_tool == &ToolType::Text { + responses.add(TextToolMessage::RefreshEditingFontData); + } } PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()), PortfolioMessage::Import => { @@ -389,13 +408,14 @@ impl MessageHandler> for Portfolio responses.add(FrontendMessage::TriggerImport); } PortfolioMessage::LoadDocumentResources { document_id } => { - if let Some(document) = self.document_mut(document_id) { - document.load_layer_resources(responses); + let catalog = &self.persistent_data.font_catalog; + + if catalog.0.is_empty() { + log::error!("Tried to load document resources before font catalog was loaded"); } - } - PortfolioMessage::LoadFont { font } => { - if !self.persistent_data.font_cache.loaded_font(&font) { - responses.add_front(FrontendMessage::TriggerFontLoad { font }); + + if let Some(document) = self.documents.get_mut(&document_id) { + document.load_layer_resources(responses, catalog); } } PortfolioMessage::NewDocumentWithName { name } => { @@ -592,7 +612,7 @@ impl MessageHandler> for Portfolio added_nodes = true; } - document.load_layer_resources(responses); + document.load_layer_resources(responses, &self.persistent_data.font_catalog); let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect(); let layer = LayerNodeIdentifier::new_unchecked(new_ids[&NodeId(0)]); all_new_ids.extend(new_ids.values().cloned()); @@ -973,7 +993,9 @@ impl MessageHandler> for Portfolio }; if !document.is_loaded { document.is_loaded = true; - responses.add(PortfolioMessage::LoadDocumentResources { document_id }); + if self.persistent_data.font_catalog.0.is_empty() { + responses.add_front(FrontendMessage::TriggerFontCatalogLoad); + } responses.add(PortfolioMessage::UpdateDocumentWidgets); responses.add(PropertiesPanelMessage::Clear); } @@ -1229,10 +1251,6 @@ impl PortfolioMessageHandler { if self.active_document().is_some() { responses.add(EventMessage::ToolAbort); responses.add(ToolMessage::DeactivateTools); - } else { - // Load the default font upon creating the first document - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()); - responses.add(FrontendMessage::TriggerFontLoad { font }); } // TODO: Remove this and find a way to fix the issue where creating a new document when the node graph is open causes the transform in the new document to be incorrect diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 4bcdd56de0..32e4e5d80f 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -1,11 +1,79 @@ -use graphene_std::text::FontCache; +use graphene_std::text::{Font, FontCache}; #[derive(Debug, Default)] pub struct PersistentData { pub font_cache: FontCache, + pub font_catalog: FontCatalog, pub use_vello: bool, } +// TODO: Should this be a BTreeMap instead? +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalog(pub Vec); + +impl FontCatalog { + pub fn find_font_style_in_catalog(&self, font: &Font) -> Option { + let family = self.0.iter().find(|family| family.name == font.font_family); + + let found_style = family.map(|family| { + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, ""); + family.closest_style(weight, italic).clone() + }); + + if found_style.is_none() { + log::warn!("Font not found in catalog: {:?}", font); + } + + found_style + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalogFamily { + /// The font family name. + pub name: String, + /// The font styles (variants) available for the font family. + pub styles: Vec, +} + +impl FontCatalogFamily { + /// Finds the closest style to the given weight and italic setting. + /// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available. + pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle { + self.styles + .iter() + .map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style)) + .min_by_key(|(distance, _)| *distance) + .map(|(_, style)| style) + .unwrap_or(&self.styles[0]) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FontCatalogStyle { + pub weight: u32, + pub italic: bool, + pub url: String, +} + +impl FontCatalogStyle { + pub fn to_named_style(&self) -> String { + let weight = self.weight; + let italic = self.italic; + + let named_weight = Font::named_weight(weight); + let maybe_italic = if italic { " Italic" } else { "" }; + + format!("{named_weight}{maybe_italic} ({weight})") + } + + pub fn from_named_style(named_style: &str, url: impl Into) -> FontCatalogStyle { + let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::().ok()).unwrap_or(400); + let italic = named_style.contains("Italic ("); + FontCatalogStyle { weight, italic, url: url.into() } + } +} + #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)] pub enum Platform { #[default] diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index 65edd20458..52f89aac57 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -44,7 +44,6 @@ impl MessageHandler> for ToolMessageHandler preferences, viewport, } = context; - let font_cache = &persistent_data.font_cache; match message { // Messages @@ -122,11 +121,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; if let Some(tool_abort_message) = tool.event_to_message_map().tool_abort { @@ -217,7 +216,7 @@ impl MessageHandler> for ToolMessageHandler tool_data.tools.get(active_tool).unwrap().activate(responses); // Register initial properties - tool_data.tools.get(active_tool).unwrap().send_layout(responses, LayoutTarget::ToolOptions); + tool_data.tools.get(active_tool).unwrap().refresh_options(responses, persistent_data); // Notify the frontend about the initial active tool tool_data.send_layout(responses, LayoutTarget::ToolShelf, preferences.brush_tool); @@ -230,11 +229,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; // Set initial hints and cursor @@ -257,7 +256,8 @@ impl MessageHandler> for ToolMessageHandler } ToolMessage::RefreshToolOptions => { let tool_data = &mut self.tool_state.tool_data; - tool_data.tools.get(&tool_data.active_tool_type).unwrap().send_layout(responses, LayoutTarget::ToolOptions); + + tool_data.tools.get(&tool_data.active_tool_type).unwrap().refresh_options(responses, persistent_data); } ToolMessage::RefreshToolShelf => { let tool_data = &mut self.tool_state.tool_data; @@ -341,11 +341,11 @@ impl MessageHandler> for ToolMessageHandler document_id, global_tool_data: &self.tool_state.document_tool_data, input, - font_cache, shape_editor: &mut self.shape_editor, node_graph, preferences, viewport, + persistent_data, }; if matches!(tool_message, ToolMessage::UpdateHints) { if graph_view_overlay_open { diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 97c42f86b0..28ecbdfcb6 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -600,7 +600,7 @@ impl Fsm for SelectToolFsmState { document, input, viewport, - font_cache, + persistent_data, .. } = tool_action_data; @@ -625,7 +625,7 @@ impl Fsm for SelectToolFsmState { overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, None); if is_layer_fed_by_node_of_name(layer, &document.network_interface, "Text") { - let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, font_cache); + let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, &persistent_data.font_cache); overlay_context.dashed_quad(transformed_quad, None, None, Some(7.), Some(5.), None); } } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 3bdbc479d0..b22393f6af 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -6,6 +6,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Transf use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::InputConnector; +use crate::messages::portfolio::utility_types::{FontCatalog, FontCatalogStyle, PersistentData}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, is_layer_fed_by_node_of_name}; @@ -13,6 +14,7 @@ use crate::messages::tool::common_functionality::resize::Resize; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData}; use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::utility_functions::text_bounding_box; +use crate::messages::tool::utility_types::ToolRefreshOptions; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; @@ -71,6 +73,7 @@ pub enum TextToolMessage { TextChange { new_text: String, is_left_or_right_click: bool }, UpdateBounds { new_text: String }, UpdateOptions { options: TextOptionsUpdate }, + RefreshEditingFontData, } #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] @@ -96,31 +99,117 @@ impl ToolMetadata for TextTool { } } -fn create_text_widgets(tool: &TextTool) -> Vec { - let font = FontInput::new(&tool.options.font_name, &tool.options.font_style) - .is_style_picker(false) - .on_update(|font_input: &FontInput| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: font_input.font_family.clone(), - style: font_input.font_style.clone(), - }, - } - .into() - }) - .widget_instance(); - let style = FontInput::new(&tool.options.font_name, &tool.options.font_style) - .is_style_picker(true) - .on_update(|font_input: &FontInput| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: font_input.font_family.clone(), - style: font_input.font_style.clone(), - }, - } - .into() - }) - .widget_instance(); +fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec { + let font = DropdownInput::new(vec![ + font_catalog + .0 + .iter() + .map(|family| { + MenuListEntry::new(family.name.clone()) + .label(family.name.clone()) + .font({ + // Get the URL for the stylesheet of a subsetted font preview for the font style closest to weight 400 + let preview_name = family.name.replace(' ', "+"); + let preview_weight = family.closest_style(400, false).weight; + format!("https://fonts.googleapis.com/css2?display=swap&family={preview_name}:wght@{preview_weight}&text={preview_name}") + }) + .on_update({ + let family = family.name.clone(); + let style = tool.options.font_style.clone(); + move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { + family: family.clone(), + style: style.clone(), + }, + } + .into() + } + }) + .on_commit({ + let family = family.name.clone(); + let style = tool.options.font_style.clone(); + move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { + family: family.clone(), + style: style.clone(), + }, + } + .into() + } + }) + }) + .collect::>(), + ]) + .selected_index(font_catalog.0.iter().position(|family| family.name == tool.options.font_name).map(|i| i as u32)) + .virtual_scrolling(true) + .widget_instance(); + + let style = DropdownInput::new({ + font_catalog + .0 + .iter() + .find(|family| family.name == tool.options.font_name) + .map(|family| { + let build_entry = |style: &FontCatalogStyle| { + let font_style = style.to_named_style(); + MenuListEntry::new(font_style.clone()) + .on_update({ + // Keep the existing family + let font_family = tool.options.font_name.clone(); + // Use the new style + let font_style = font_style.clone(); + move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { + family: font_family.clone(), + style: font_style.clone(), + }, + } + .into() + } + }) + .on_commit({ + // Keep the existing family + let font_family = tool.options.font_name.clone(); + // Use the new style + let font_style = font_style.clone(); + move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { + family: font_family.clone(), + style: font_style.clone(), + }, + } + .into() + } + }) + .label(font_style) + }; + + vec![ + family.styles.iter().filter(|style| !style.italic).map(build_entry).collect::>(), + family.styles.iter().filter(|style| style.italic).map(build_entry).collect::>(), + ] + }) + .filter(|styles| !styles.is_empty()) + .unwrap_or_default() + }) + .selected_index( + font_catalog + .0 + .iter() + .find(|family| family.name == tool.options.font_name) + .and_then(|family| { + let not_italic = family.styles.iter().filter(|style| !style.italic); + let italic = family.styles.iter().filter(|style| style.italic); + not_italic.chain(italic).position(|style| style.to_named_style() == tool.options.font_style) + }) + .map(|i| i as u32), + ) + .widget_instance(); + let size = NumberInput::new(Some(tool.options.font_size)) .unit(" px") .label("Size") @@ -172,9 +261,22 @@ fn create_text_widgets(tool: &TextTool) -> Vec { ] } -impl LayoutHolder for TextTool { - fn layout(&self) -> Layout { - let mut widgets = create_text_widgets(self); +impl ToolRefreshOptions for TextTool { + fn refresh_options(&self, responses: &mut VecDeque, persistent_data: &PersistentData) { + self.send_layout(responses, LayoutTarget::ToolOptions, &persistent_data.font_catalog); + } +} + +impl TextTool { + fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog) { + responses.add(LayoutMessage::SendLayout { + layout: self.layout(font_catalog), + layout_target, + }); + } + + fn layout(&self, font_catalog: &FontCatalog) -> Layout { + let mut widgets = create_text_widgets(self, font_catalog); widgets.push(Separator::new(SeparatorType::Unrelated).widget_instance()); @@ -218,8 +320,6 @@ impl<'a> MessageHandler> for Text TextOptionsUpdate::Font { family, style } => { self.options.font_name = family; self.options.font_style = style; - - self.send_layout(responses, LayoutTarget::ToolOptions); } TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size, TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio, @@ -235,7 +335,7 @@ impl<'a> MessageHandler> for Text } } - self.send_layout(responses, LayoutTarget::ToolOptions); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.persistent_data.font_catalog); } fn actions(&self) -> ActionList { @@ -339,6 +439,7 @@ impl TextToolData { TextToolFsmState::Ready } + /// Set the editing state of the currently modifying layer fn set_editing(&self, editable: bool, font_cache: &FontCache, responses: &mut VecDeque) { if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) { @@ -347,7 +448,7 @@ impl TextToolData { line_height_ratio: editing_text.typesetting.line_height_ratio, font_size: editing_text.typesetting.font_size, color: editing_text.color.unwrap_or(Color::BLACK), - url: font_cache.get_preview_url(&editing_text.font).cloned().unwrap_or_default(), + font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default(), transform: editing_text.transform.to_cols_array(), max_width: editing_text.typesetting.max_width, max_height: editing_text.typesetting.max_height, @@ -411,6 +512,7 @@ impl TextToolData { self.layer = LayerNodeIdentifier::new_unchecked(NodeId::new()); + responses.add(PortfolioMessage::LoadFontData { font: editing_text.font.clone() }); responses.add(GraphOperationMessage::NewTextLayer { id: self.layer.to_node(), text: String::new(), @@ -498,10 +600,11 @@ impl Fsm for TextToolFsmState { document, global_tool_data, input, - font_cache, + persistent_data, viewport, .. } = transition_data; + let font_cache = &persistent_data.font_cache; let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) .unwrap() .with_alpha(0.05) @@ -852,6 +955,14 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Ready } + (TextToolFsmState::Editing, TextToolMessage::RefreshEditingFontData) => { + let font = Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()); + responses.add(FrontendMessage::DisplayEditableTextboxUpdateFontData { + font_data: font_cache.get(&font).map(|(data, _)| data.clone()).unwrap_or_default(), + }); + + TextToolFsmState::Editing + } (TextToolFsmState::Editing, TextToolMessage::TextChange { new_text, is_left_or_right_click }) => { tool_data.new_text = new_text; @@ -871,6 +982,7 @@ impl Fsm for TextToolFsmState { } responses.add(FrontendMessage::TriggerTextCommit); + TextToolFsmState::Editing } } diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index e9e7a17006..052926cfe7 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -9,12 +9,12 @@ use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::input_mapper::utility_types::misc::ActionShortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::overlays::utility_types::OverlayProvider; +use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::preferences::PreferencesMessageHandler; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeType; use crate::node_graph_executor::NodeGraphExecutor; use graphene_std::raster::color::Color; -use graphene_std::text::FontCache; use std::borrow::Cow; use std::fmt::{self, Debug}; @@ -24,18 +24,28 @@ pub struct ToolActionMessageContext<'a> { pub document_id: DocumentId, pub global_tool_data: &'a DocumentToolData, pub input: &'a InputPreprocessorMessageHandler, - pub font_cache: &'a FontCache, + pub persistent_data: &'a PersistentData, pub shape_editor: &'a mut ShapeState, pub node_graph: &'a NodeGraphExecutor, pub preferences: &'a PreferencesMessageHandler, pub viewport: &'a ViewportMessageHandler, } -pub trait ToolCommon: for<'a, 'b> MessageHandler> + LayoutHolder + ToolTransition + ToolMetadata {} -impl ToolCommon for T where T: for<'a, 'b> MessageHandler> + LayoutHolder + ToolTransition + ToolMetadata {} +pub trait ToolCommon: for<'a, 'b> MessageHandler> + ToolRefreshOptions + ToolTransition + ToolMetadata {} +impl ToolCommon for T where T: for<'a, 'b> MessageHandler> + ToolRefreshOptions + ToolTransition + ToolMetadata {} type Tool = dyn ToolCommon + Send + Sync; +pub trait ToolRefreshOptions { + fn refresh_options(&self, responses: &mut VecDeque, _persistent_data: &PersistentData); +} + +impl ToolRefreshOptions for T { + fn refresh_options(&self, responses: &mut VecDeque, _persistent_data: &PersistentData) { + self.send_layout(responses, LayoutTarget::ToolOptions); + } +} + /// The FSM (finite state machine) is a flowchart between different operating states that a specific tool might be in. /// It is the central "core" logic area of each tool which is in charge of maintaining the state of the tool and responding to events coming from outside (like user input). /// For example, a tool might be `Ready` or `Drawing` depending on if the user is idle or actively drawing with the mouse held down. diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index 9831e018e3..851d148b88 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -11,7 +11,7 @@ import { createAppWindowState } from "@graphite/state-providers/app-window"; import { createDialogState } from "@graphite/state-providers/dialog"; import { createDocumentState } from "@graphite/state-providers/document"; - import { createFontsState } from "@graphite/state-providers/fonts"; + import { createFontsManager } from "/src/io-managers/fonts"; import { createFullscreenState } from "@graphite/state-providers/fullscreen"; import { createNodeGraphState } from "@graphite/state-providers/node-graph"; import { createPortfolioState } from "@graphite/state-providers/portfolio"; @@ -31,8 +31,6 @@ setContext("tooltip", tooltip); let document = createDocumentState(editor); setContext("document", document); - let fonts = createFontsState(editor); - setContext("fonts", fonts); let fullscreen = createFullscreenState(editor); setContext("fullscreen", fullscreen); let nodeGraph = createNodeGraphState(editor); @@ -48,6 +46,7 @@ createLocalizationManager(editor); createPanicManager(editor, dialog); createPersistenceManager(editor, portfolio); + createFontsManager(editor); let inputManagerDestructor = createInputManager(editor, dialog, portfolio, document, fullscreen); onMount(() => { diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte index 6c1f847172..116d21d166 100644 --- a/frontend/src/components/floating-menus/MenuList.svelte +++ b/frontend/src/components/floating-menus/MenuList.svelte @@ -30,6 +30,7 @@ export let parentsValuePath: string[] = []; export let entries: MenuListEntry[][]; + export let entriesHash: bigint; export let activeEntry: MenuListEntry | undefined = undefined; export let open: boolean; export let direction: MenuDirection = "Bottom"; @@ -37,26 +38,29 @@ export let drawIcon = false; export let interactive = false; export let scrollableY = false; - export let virtualScrollingEntryHeight = 0; + export let virtualScrolling = false; // Keep the child references outside of the entries array so as to avoid infinite recursion. let childReferences: MenuList[][] = []; let search = ""; - + let reactiveEntries = entries; let highlighted = activeEntry as MenuListEntry | undefined; let virtualScrollingEntriesStart = 0; - // Called only when `open` is changed from outside this component + // `watchOpen` is called only when `open` is changed from outside this component $: watchOpen(open); $: watchEntries(entries); + $: watchEntriesHash(entriesHash); $: watchRemeasureWidth(filteredEntries, drawIcon); $: watchHighlightedWithSearch(filteredEntries, open); - $: filteredEntries = entries.map((section) => section.filter((entry) => inSearch(search, entry))); + $: virtualScrollingEntryHeight = virtualScrolling ? 20 : 0; + $: filteredEntries = reactiveEntries.map((section) => section.filter((entry) => inSearch(search, entry))); + $: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0; + // Virtual scrolling calculations $: virtualScrollingTotalHeight = filteredEntries.length === 0 ? 0 : filteredEntries[0].length * virtualScrollingEntryHeight; - $: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0; + $: virtualScrollingStartIndex = filteredEntries.length === 0 ? 0 : Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0; $: virtualScrollingEndIndex = filteredEntries.length === 0 ? 0 : Math.min(filteredEntries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight); - $: startIndex = virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0; // TODO: Move keyboard input handling entirely to the unified system in `input.ts`. // TODO: The current approach is hacky and blocks the allowances for shortcuts like the key to open the browser's dev tools. @@ -139,6 +143,10 @@ }); } + function watchEntriesHash(_entriesHash: bigint) { + reactiveEntries = entries; + } + function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) { self?.measureAndEmitNaturalWidth(); } @@ -149,23 +157,31 @@ } function getChildReference(menuListEntry: MenuListEntry): MenuList | undefined { - const index = filteredEntries.flat().indexOf(menuListEntry); - return childReferences.flat().filter((x) => x)[index]; + const index = filteredEntries.flat().findIndex((entry) => entry.value === menuListEntry.value); + + if (index !== -1) { + return childReferences.flat().filter((x) => x)[index]; + } else { + // eslint-disable-next-line no-console + console.error("MenuListEntry not found in filteredEntries:", menuListEntry); + return undefined; + } } function onEntryClick(menuListEntry: MenuListEntry) { - // Notify the parent about the clicked entry as the new active entry - dispatch("activeEntry", menuListEntry); - dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]); - // Close the containing menu let childReference = getChildReference(menuListEntry); if (childReference) { childReference.open = false; - entries = entries; + reactiveEntries = reactiveEntries; } dispatch("open", false); open = false; + reactiveEntries = reactiveEntries; + + // Notify the parent about the clicked entry as the new active entry + dispatch("activeEntry", menuListEntry); + dispatch("selectedEntryValuePath", [...parentsValuePath, menuListEntry.value]); } function onEntryPointerEnter(menuListEntry: MenuListEntry) { @@ -177,8 +193,10 @@ let childReference = getChildReference(menuListEntry); if (childReference) { childReference.open = true; - entries = entries; - } else dispatch("open", true); + reactiveEntries = reactiveEntries; + } else { + dispatch("open", true); + } } function onEntryPointerLeave(menuListEntry: MenuListEntry) { @@ -190,8 +208,10 @@ let childReference = getChildReference(menuListEntry); if (childReference) { childReference.open = false; - entries = entries; - } else dispatch("open", false); + reactiveEntries = reactiveEntries; + } else { + dispatch("open", false); + } } function isEntryOpen(menuListEntry: MenuListEntry): boolean { @@ -365,7 +385,7 @@ let container = scroller?.div?.(); if (!container || !highlighted) return; let containerBoundingRect = container.getBoundingClientRect(); - let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry === highlighted); + let highlightedIndex = filteredEntries.flat().findIndex((entry) => entry.value === highlighted?.value); let selectedBoundingRect = new DOMRect(); if (virtualScrollingEntryHeight) { @@ -386,10 +406,6 @@ container.scrollBy(0, selectedBoundingRect.y - (containerBoundingRect.y + containerBoundingRect.height) + selectedBoundingRect.height); } } - - export function scrollViewTo(distanceDown: number) { - scroller?.div?.()?.scrollTo(0, distanceDown); - } {/if} - {#each entries as section, sectionIndex (sectionIndex)} - {#if includeSeparator(entries, section, sectionIndex, search)} + {#each reactiveEntries as section, sectionIndex (sectionIndex)} + {#if includeSeparator(reactiveEntries, section, sectionIndex, search)} {/if} {#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)} @@ -442,10 +458,10 @@ {/if} {#if entry.font} - + {/if} - + {#if entry.tooltipShortcut?.shortcut.length} @@ -470,6 +486,7 @@ open={getChildReference(entry)?.open || false} direction="TopRight" entries={entry.children} + entriesHash={entry.childrenHash || 0n} {minWidth} {drawIcon} {scrollableY} diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index f149cb3fdc..8c03e135be 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -6,6 +6,7 @@ type MouseCursorIcon, type XY, DisplayEditableTextbox, + DisplayEditableTextboxUpdateFontData, DisplayEditableTextboxTransform, DisplayRemoveEditableTextbox, TriggerTextCommit, @@ -360,10 +361,14 @@ if (!textInput) return; editor.handle.updateBounds(textInputCleanup(textInput.innerText)); }; + textInputMatrix = displayEditableTextbox.transform; - const newFont = new FontFace("text-font", `url(${displayEditableTextbox.url})`); - window.document.fonts.add(newFont); - textInput.style.fontFamily = "text-font"; + + const data = new Uint8Array(displayEditableTextbox.fontData); + if (data.length > 0) { + window.document.fonts.add(new FontFace("text-font", data)); + textInput.style.fontFamily = "text-font"; + } // Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060 @@ -471,6 +476,15 @@ displayEditableTextbox(data); }); + editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxUpdateFontData, async (data) => { + await tick(); + + const fontData = new Uint8Array(data.fontData); + if (fontData.length > 0 && textInput) { + window.document.fonts.add(new FontFace("text-font", fontData)); + textInput.style.fontFamily = "text-font"; + } + }); editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxTransform, async (data) => { textInputMatrix = data.transform; }); diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 8658128bcc..c4e474bc07 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -60,16 +60,16 @@ return widgets; } - function widgetValueCommit(index: number, value: unknown) { - editor.handle.widgetValueCommit(layoutTarget, widgets[index].widgetId, value); + function widgetValueCommit(widgetIndex: number, value: unknown) { + editor.handle.widgetValueCommit(layoutTarget, widgets[widgetIndex].widgetId, value); } - function widgetValueUpdate(index: number, value: unknown) { - editor.handle.widgetValueUpdate(layoutTarget, widgets[index].widgetId, value); + function widgetValueUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) { + editor.handle.widgetValueUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); } - function widgetValueCommitAndUpdate(index: number, value: unknown) { - editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[index].widgetId, value); + function widgetValueCommitAndUpdate(widgetIndex: number, value: unknown, resendWidget: boolean) { + editor.handle.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); } // TODO: This seems to work, but verify the correctness and terseness of this, it's adapted from https://stackoverflow.com/a/67434028/775283 @@ -84,39 +84,47 @@
- {#each widgets as component, index} + {#each widgets as component, widgetIndex} {@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")} {#if checkboxInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const colorInput = narrowWidgetProps(component.props, "ColorInput")} {#if colorInput} - widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} /> + widgetValueUpdate(widgetIndex, detail, false)} + on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, colorInput.value)} + /> {/if} + {@const curvesInput = narrowWidgetProps(component.props, "CurveInput")} {#if curvesInput} - debouncer((value) => widgetValueCommitAndUpdate(index, value), { debounceTime: 120 }).debounceUpdateValue(detail)} /> + debouncer((value) => widgetValueCommitAndUpdate(widgetIndex, value, false), { debounceTime: 120 }).debounceUpdateValue(detail)} + /> {/if} {@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")} {#if dropdownInput} { - return widgetValueUpdate(index, detail); + return widgetValueUpdate(widgetIndex, detail, false); }} on:hoverOutEntry={({ detail }) => { - return widgetValueUpdate(index, detail); + return widgetValueUpdate(widgetIndex, detail, false); }} - on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(index, detail)} + on:selectedIndex={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const parameterExposeButton = narrowWidgetProps(component.props, "ParameterExposeButton")} {#if parameterExposeButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const iconButton = narrowWidgetProps(component.props, "IconButton")} {#if iconButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const iconLabel = narrowWidgetProps(component.props, "IconLabel")} {#if iconLabel} @@ -133,25 +141,25 @@ {/if} {@const imageButton = narrowWidgetProps(component.props, "ImageButton")} {#if imageButton} - widgetValueCommitAndUpdate(index, undefined)} /> + widgetValueCommitAndUpdate(widgetIndex, undefined, true)} /> {/if} {@const nodeCatalog = narrowWidgetProps(component.props, "NodeCatalog")} {#if nodeCatalog} - widgetValueCommitAndUpdate(index, e.detail)} /> + widgetValueCommitAndUpdate(widgetIndex, e.detail, false)} /> {/if} {@const numberInput = narrowWidgetProps(component.props, "NumberInput")} {#if numberInput} debouncer((value) => widgetValueUpdate(index, value)).debounceUpdateValue(detail)} - on:startHistoryTransaction={() => widgetValueCommit(index, numberInput.value)} - incrementCallbackIncrease={() => widgetValueCommitAndUpdate(index, "Increment")} - incrementCallbackDecrease={() => widgetValueCommitAndUpdate(index, "Decrement")} + on:value={({ detail }) => debouncer((value) => widgetValueUpdate(widgetIndex, value, true)).debounceUpdateValue(detail)} + on:startHistoryTransaction={() => widgetValueCommit(widgetIndex, numberInput.value)} + incrementCallbackIncrease={() => widgetValueCommitAndUpdate(widgetIndex, "Increment", false)} + incrementCallbackDecrease={() => widgetValueCommitAndUpdate(widgetIndex, "Decrement", false)} /> {/if} {@const referencePointInput = narrowWidgetProps(component.props, "ReferencePointInput")} {#if referencePointInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const popoverButton = narrowWidgetProps(component.props, "PopoverButton")} {#if popoverButton} @@ -161,7 +169,7 @@ {/if} {@const radioInput = narrowWidgetProps(component.props, "RadioInput")} {#if radioInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const separator = narrowWidgetProps(component.props, "Separator")} {#if separator} @@ -173,19 +181,23 @@ {/if} {@const textAreaInput = narrowWidgetProps(component.props, "TextAreaInput")} {#if textAreaInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, false)} /> {/if} {@const textButton = narrowWidgetProps(component.props, "TextButton")} {#if textButton} - widgetValueCommitAndUpdate(index, [])} on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, [], true)} + on:selectedEntryValuePath={({ detail }) => widgetValueCommitAndUpdate(widgetIndex, detail, false)} + /> {/if} {@const breadcrumbTrailButtons = narrowWidgetProps(component.props, "BreadcrumbTrailButtons")} {#if breadcrumbTrailButtons} - widgetValueCommitAndUpdate(index, breadcrumbIndex)} /> + widgetValueCommitAndUpdate(widgetIndex, breadcrumbIndex, true)} /> {/if} {@const textInput = narrowWidgetProps(component.props, "TextInput")} {#if textInput} - widgetValueCommitAndUpdate(index, detail)} /> + widgetValueCommitAndUpdate(widgetIndex, detail, true)} /> {/if} {@const textLabel = narrowWidgetProps(component.props, "TextLabel")} {#if textLabel} diff --git a/frontend/src/components/widgets/buttons/TextButton.svelte b/frontend/src/components/widgets/buttons/TextButton.svelte index 49d78eb151..94de4b2f56 100644 --- a/frontend/src/components/widgets/buttons/TextButton.svelte +++ b/frontend/src/components/widgets/buttons/TextButton.svelte @@ -27,6 +27,7 @@ export let tooltipDescription: string | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined; export let menuListChildren: MenuListEntry[][] | undefined = undefined; + export let menuListChildrenHash: bigint | undefined = undefined; // Callbacks // TODO: Replace this with an event binding (and on other components that do this) @@ -90,6 +91,7 @@ on:selectedEntryValuePath={({ detail }) => dispatch("selectedEntryValuePath", detail)} open={self?.open || false} entries={menuListChildren || []} + entriesHash={menuListChildrenHash || 0n} direction="Bottom" minWidth={240} drawIcon={true} diff --git a/frontend/src/components/widgets/inputs/DropdownInput.svelte b/frontend/src/components/widgets/inputs/DropdownInput.svelte index 4cfb031ea9..a7c10d63c5 100644 --- a/frontend/src/components/widgets/inputs/DropdownInput.svelte +++ b/frontend/src/components/widgets/inputs/DropdownInput.svelte @@ -12,15 +12,16 @@ const dispatch = createEventDispatcher<{ selectedIndex: number; hoverInEntry: number; hoverOutEntry: number }>(); - let menuList: MenuList | undefined; let self: LayoutRow | undefined; export let entries: MenuListEntry[][]; + export let entriesHash: bigint | undefined = undefined; export let selectedIndex: number | undefined = undefined; // When not provided, a dash is displayed export let drawIcon = false; export let interactive = true; export let disabled = false; export let narrow = false; + export let virtualScrolling = false; export let tooltipLabel: string | undefined = undefined; export let tooltipDescription: string | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined; @@ -53,19 +54,32 @@ activeEntry = makeActiveEntry(); } - // Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `selectedIndex()` watcher above (but we want to skip that case) + // Called when the `activeEntry` two-way binding on this component's MenuList component is changed, or by the `watchSelectedIndex()` watcher above (but we want to skip that case) function watchActiveEntry(activeEntry: MenuListEntry) { if (activeEntrySkipWatcher) { activeEntrySkipWatcher = false; } else if (activeEntry !== DASH_ENTRY) { // We need to set to the initial value first to track a right history step, as if we hover in initial selection. if (initialSelectedIndex !== undefined) dispatch("hoverInEntry", initialSelectedIndex); - dispatch("selectedIndex", entries.flat().indexOf(activeEntry)); + const index = entries.flat().findIndex((entry) => entry.value === activeEntry.value); + if (index !== -1) { + dispatch("selectedIndex", index); + } else { + // eslint-disable-next-line no-console + console.error("Selected index not found in entries:", activeEntry); + } } } function dispatchHoverInEntry(hoveredEntry: MenuListEntry) { - dispatch("hoverInEntry", entries.flat().indexOf(hoveredEntry)); + const index = entries.flat().findIndex((entry) => entry.value === hoveredEntry.value); + + if (index !== -1) { + dispatch("hoverInEntry", index); + } else { + // eslint-disable-next-line no-console + console.error("Hovered entry not found in entries:", hoveredEntry); + } } function dispatchHoverOutEntry() { @@ -123,11 +137,12 @@ {open} {activeEntry} {entries} + entriesHash={entriesHash || 0n} {drawIcon} {interactive} + {virtualScrolling} direction="Bottom" scrollableY={true} - bind:this={menuList} /> diff --git a/frontend/src/io-managers/fonts.ts b/frontend/src/io-managers/fonts.ts new file mode 100644 index 0000000000..edd931b587 --- /dev/null +++ b/frontend/src/io-managers/fonts.ts @@ -0,0 +1,44 @@ +import { type Editor } from "@graphite/editor"; +import { TriggerFontCatalogLoad, TriggerFontDataLoad } from "@graphite/messages"; + +type ApiResponse = { family: string; variants: string[]; files: Record }[]; + +const FONT_LIST_API = "https://api.graphite.art/font-list"; + +export function createFontsManager(editor: Editor) { + // Subscribe to process backend events + editor.subscriptions.subscribeJsMessage(TriggerFontCatalogLoad, async () => { + const response = await fetch(FONT_LIST_API); + const fontListResponse = (await response.json()) as { items: ApiResponse }; + 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.handle.onFontCatalogLoad(catalog); + }); + + editor.subscriptions.subscribeJsMessage(TriggerFontDataLoad, async (triggerFontDataLoad) => { + const { fontFamily, fontStyle } = triggerFontDataLoad.font; + + try { + if (!triggerFontDataLoad.url) throw new Error("No URL provided for font data load"); + const response = await fetch(triggerFontDataLoad.url); + const buffer = await response.arrayBuffer(); + const data = new Uint8Array(buffer); + + editor.handle.onFontLoad(fontFamily, fontStyle, data); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to load font:", error); + } + }); +} diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts index 3ff199c839..a7f44b70d0 100644 --- a/frontend/src/messages.ts +++ b/frontend/src/messages.ts @@ -793,7 +793,7 @@ export class DisplayEditableTextbox extends JsMessage { @Type(() => Color) readonly color!: Color; - readonly url!: string; + readonly fontData!: ArrayBuffer; readonly transform!: number[]; @@ -804,6 +804,10 @@ export class DisplayEditableTextbox extends JsMessage { readonly align!: TextAlign; } +export class DisplayEditableTextboxUpdateFontData extends JsMessage { + readonly fontData!: ArrayBuffer; +} + export class DisplayEditableTextboxTransform extends JsMessage { readonly transform!: number[]; } @@ -865,9 +869,13 @@ export class Font { fontStyle!: string; } -export class TriggerFontLoad extends JsMessage { +export class TriggerFontCatalogLoad extends JsMessage {} + +export class TriggerFontDataLoad extends JsMessage { @Type(() => Font) font!: Font; + + url!: string; } export class TriggerVisitLink extends JsMessage { @@ -998,13 +1006,14 @@ export function contrastingOutlineFactor(value: FillChoice, proximityColor: stri export type MenuListEntry = { value: string; label: string; - font?: URL; + font?: string; icon?: IconName; disabled?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: ActionShortcut; children?: MenuListEntry[][]; + childrenHash?: bigint; }; export class CurveManipulatorGroup { @@ -1036,6 +1045,8 @@ export class CurveInput extends WidgetProps { export class DropdownInput extends WidgetProps { entries!: MenuListEntry[][]; + entriesHash!: bigint; + selectedIndex!: number | undefined; drawIcon!: boolean; @@ -1046,6 +1057,8 @@ export class DropdownInput extends WidgetProps { narrow!: boolean; + virtualScrolling!: boolean; + @Transform(({ value }: { value: string }) => value || undefined) tooltipLabel!: string | undefined; @@ -1330,6 +1343,8 @@ export class TextButton extends WidgetProps { tooltipShortcut!: ActionShortcut | undefined; menuListChildren!: MenuListEntry[][]; + + menuListChildrenHash!: bigint; } export class BreadcrumbTrailButtons extends WidgetProps { @@ -1675,6 +1690,7 @@ export const messageMakers: Record = { DisplayDialogDismiss, DisplayDialogPanic, DisplayEditableTextbox, + DisplayEditableTextboxUpdateFontData, DisplayEditableTextboxTransform, DisplayRemoveEditableTextbox, SendUIMetadata, @@ -1684,7 +1700,8 @@ export const messageMakers: Record = { TriggerDisplayThirdPartyLicensesDialog, TriggerExportImage, TriggerFetchAndOpenDocument, - TriggerFontLoad, + TriggerFontCatalogLoad, + TriggerFontDataLoad, TriggerImport, TriggerLoadFirstAutoSaveDocument, TriggerLoadPreferences, diff --git a/frontend/src/state-providers/fonts.ts b/frontend/src/state-providers/fonts.ts deleted file mode 100644 index f364c36076..0000000000 --- a/frontend/src/state-providers/fonts.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { writable } from "svelte/store"; - -import { type Editor } from "@graphite/editor"; -import { TriggerFontLoad } from "@graphite/messages"; - -export function createFontsState(editor: Editor) { - // TODO: Do some code cleanup to remove the need for this empty store - const { subscribe } = writable({}); - - function createURL(font: string, weight: string): URL { - const url = new URL("https://fonts.googleapis.com/css2"); - url.searchParams.set("display", "swap"); - url.searchParams.set("family", `${font}:wght@${weight}`); - url.searchParams.set("text", font); - - return url; - } - - async function fontNames(): Promise<{ name: string; url: URL | undefined }[]> { - const pickPreviewWeight = (variants: string[]) => { - const weights = variants.map((variant) => Number(variant.match(/.* \((\d+)\)/)?.[1] || "NaN")); - const weightGoal = 400; - const sorted = weights.map((weight) => [weight, Math.abs(weightGoal - weight - 1)]); - sorted.sort(([_, a], [__, b]) => a - b); - return sorted[0][0].toString(); - }; - return (await loadFontList()).map((font) => ({ name: font.family, url: createURL(font.family, pickPreviewWeight(font.variants)) })); - } - - async function getFontStyles(fontFamily: string): Promise<{ name: string; url: URL | undefined }[]> { - const font = (await loadFontList()).find((value) => value.family === fontFamily); - return font?.variants.map((variant) => ({ name: variant, url: undefined })) || []; - } - - async function getFontFileUrl(fontFamily: string, fontStyle: string): Promise { - const font = (await loadFontList()).find((value) => value.family === fontFamily); - const fontFileUrl = font?.files.get(fontStyle); - return fontFileUrl?.replace("http://", "https://"); - } - - function formatFontStyleName(fontStyle: string): string { - const isItalic = fontStyle.endsWith("italic"); - const weight = fontStyle === "regular" || fontStyle === "italic" ? 400 : parseInt(fontStyle, 10); - let weightName = ""; - - let bestWeight = Infinity; - weightNameMapping.forEach((nameChecking, weightChecking) => { - if (Math.abs(weightChecking - weight) < bestWeight) { - bestWeight = Math.abs(weightChecking - weight); - weightName = nameChecking; - } - }); - - return `${weightName}${isItalic ? " Italic" : ""} (${weight})`; - } - - let fontList: Promise<{ family: string; variants: string[]; files: Map }[]> | undefined; - - async function loadFontList(): Promise<{ family: string; variants: string[]; files: Map }[]> { - if (fontList) return fontList; - - fontList = new Promise<{ family: string; variants: string[]; files: Map }[]>((resolve) => { - fetch(fontListAPI) - .then((response) => response.json()) - .then((fontListResponse) => { - const fontListData = fontListResponse.items as { family: string; variants: string[]; files: Record }[]; - const result = fontListData.map((font) => { - const { family } = font; - const variants = font.variants.map(formatFontStyleName); - const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]])); - return { family, variants, files }; - }); - - resolve(result); - }); - }); - - return fontList; - } - - // Subscribe to process backend events - editor.subscriptions.subscribeJsMessage(TriggerFontLoad, async (triggerFontLoad) => { - const url = await getFontFileUrl(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle); - if (url) { - const response = await (await fetch(url)).arrayBuffer(); - editor.handle.onFontLoad(triggerFontLoad.font.fontFamily, triggerFontLoad.font.fontStyle, url, new Uint8Array(response)); - } else { - editor.handle.errorDialog("Failed to load font", `The font ${triggerFontLoad.font.fontFamily} with style ${triggerFontLoad.font.fontStyle} does not exist`); - } - }); - - return { - subscribe, - fontNames, - getFontStyles, - getFontFileUrl, - }; -} -export type FontsState = ReturnType; - -const fontListAPI = "https://api.graphite.art/font-list"; - -// From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping -const weightNameMapping = new Map([ - [100, "Thin"], - [200, "Extra Light"], - [300, "Light"], - [400, "Regular"], - [500, "Medium"], - [600, "Semi Bold"], - [700, "Bold"], - [800, "Extra Bold"], - [900, "Black"], - [950, "Extra Black"], -]); diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 05d02bf20f..8f386c3879 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -12,7 +12,7 @@ use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; -use editor::messages::portfolio::utility_types::Platform; +use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, Platform}; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; use graph_craft::document::NodeId; @@ -116,7 +116,6 @@ impl EditorHandle { #[cfg(not(feature = "native"))] fn dispatch>(&self, message: T) { // Process no further messages after a crash to avoid spamming the console - use crate::MESSAGE_BUFFER; if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { return; @@ -330,21 +329,43 @@ impl EditorHandle { /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) #[wasm_bindgen(js_name = widgetValueUpdate)] - pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { + self.widget_value_update_helper(layout_target, widget_id, value, resend_widget) + } + + /// Commit the value of a given UI widget to the history + #[wasm_bindgen(js_name = widgetValueCommit)] + pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + self.widget_value_commit_helper(layout_target, widget_id, value) + } + + /// Update the value of a given UI widget, and commit it to the history + #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] + pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { + self.widget_value_commit_helper(layout_target.clone(), widget_id, value.clone())?; + self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?; + Ok(()) + } + + pub fn widget_value_update_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { let widget_id = WidgetId(widget_id); match (from_value(layout_target), from_value(value)) { (Ok(layout_target), Ok(value)) => { let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; self.dispatch(message); + + if resend_widget { + let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id }; + self.dispatch(resend_message); + } + Ok(()) } (target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), } } - /// Commit the value of a given UI widget to the history - #[wasm_bindgen(js_name = widgetValueCommit)] - pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + pub fn widget_value_commit_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { let widget_id = WidgetId(widget_id); match (from_value(layout_target), from_value(value)) { (Ok(layout_target), Ok(value)) => { @@ -356,14 +377,6 @@ impl EditorHandle { } } - /// Update the value of a given UI widget, and commit it to the history - #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] - pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { - self.widget_value_commit(layout_target.clone(), widget_id, value.clone())?; - self.widget_value_update(layout_target, widget_id, value)?; - Ok(()) - } - #[wasm_bindgen(js_name = loadPreferences)] pub fn load_preferences(&self, preferences: Option) { let preferences = if let Some(preferences) = preferences { @@ -562,15 +575,21 @@ impl EditorHandle { Ok(()) } + /// The font catalog has been loaded + #[wasm_bindgen(js_name = onFontCatalogLoad)] + pub fn on_font_catalog_load(&self, catalog: JsValue) -> Result<(), JsValue> { + // Deserializing from TS type: `{ name: string; styles: { weight: number, italic: boolean, url: string }[] }[]` + let families = serde_wasm_bindgen::from_value::>(catalog)?; + let message = PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(families) }; + self.dispatch(message); + + Ok(()) + } + /// A font has been downloaded #[wasm_bindgen(js_name = onFontLoad)] - pub fn on_font_load(&self, font_family: String, font_style: String, preview_url: String, data: Vec) -> Result<(), JsValue> { - let message = PortfolioMessage::FontLoaded { - font_family, - font_style, - preview_url, - data, - }; + pub fn on_font_load(&self, font_family: String, font_style: String, data: Vec) -> Result<(), JsValue> { + let message = PortfolioMessage::FontLoaded { font_family, font_style, data }; self.dispatch(message); Ok(()) diff --git a/node-graph/libraries/core-types/src/consts.rs b/node-graph/libraries/core-types/src/consts.rs index 0487100e42..b2dd51562d 100644 --- a/node-graph/libraries/core-types/src/consts.rs +++ b/node-graph/libraries/core-types/src/consts.rs @@ -5,5 +5,5 @@ pub const LAYER_OUTLINE_STROKE_COLOR: Color = Color::BLACK; pub const LAYER_OUTLINE_STROKE_WEIGHT: f64 = 0.5; // Fonts -pub const DEFAULT_FONT_FAMILY: &str = "Cabin"; +pub const DEFAULT_FONT_FAMILY: &str = "Lato"; pub const DEFAULT_FONT_STYLE: &str = "Regular (400)"; diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 00d7cf3cd5..9dfe656351 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -8,7 +8,7 @@ fn text<'i: 'n>( _: impl Ctx, editor: &'i WasmEditorApi, text: String, - font_name: Font, + font: Font, #[unit(" px")] #[default(24.)] font_size: f64, @@ -39,5 +39,5 @@ fn text<'i: 'n>( align, }; - to_path(&text, &font_name, &editor.font_cache, typesetting, per_glyph_instances) + to_path(&text, &font, &editor.font_cache, typesetting, per_glyph_instances) } diff --git a/node-graph/nodes/text/src/font_cache.rs b/node-graph/nodes/text/src/font_cache.rs index 7cdee13c11..8875448897 100644 --- a/node-graph/nodes/text/src/font_cache.rs +++ b/node-graph/nodes/text/src/font_cache.rs @@ -7,16 +7,55 @@ use std::sync::Arc; use core_types::specta; /// A font type (storing font family and font style and an optional preview URL) -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, DynAny, core_types::specta::Type)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Eq, DynAny, core_types::specta::Type)] pub struct Font { #[serde(rename = "fontFamily")] pub font_family: String, #[serde(rename = "fontStyle", deserialize_with = "migrate_font_style")] pub font_style: String, + #[serde(skip)] + pub font_style_to_restore: Option, } + +impl std::hash::Hash for Font { + fn hash(&self, state: &mut H) { + self.font_family.hash(state); + self.font_style.hash(state); + // Don't consider `font_style_to_restore` in the HashMaps + } +} + +impl PartialEq for Font { + fn eq(&self, other: &Self) -> bool { + // Don't consider `font_style_to_restore` in the HashMaps + self.font_family == other.font_family && self.font_style == other.font_style + } +} + impl Font { pub fn new(font_family: String, font_style: String) -> Self { - Self { font_family, font_style } + Self { + font_family, + font_style, + font_style_to_restore: None, + } + } + + pub fn named_weight(weight: u32) -> &'static str { + // From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping + match weight { + 100 => "Thin", + 200 => "Extra Light", + 300 => "Light", + 400 => "Regular", + 500 => "Medium", + 600 => "Semi Bold", + 700 => "Bold", + 800 => "Extra Bold", + 900 => "Black", + 950 => "Extra Black", + _ => "Regular", + } } } impl Default for Font { @@ -24,21 +63,33 @@ impl Default for Font { Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into()) } } + /// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`) -#[derive(Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, DynAny)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Default, DynAny)] pub struct FontCache { /// Actual font file data used for rendering a font font_file_data: HashMap>, - /// Web font preview URLs used for showing fonts when live editing - preview_urls: HashMap, } impl std::fmt::Debug for FontCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FontCache") - .field("font_file_data", &self.font_file_data.keys().collect::>()) - .field("preview_urls", &self.preview_urls) - .finish() + f.debug_struct("FontCache").field("font_file_data", &self.font_file_data.keys().collect::>()).finish() + } +} + +impl std::hash::Hash for FontCache { + fn hash(&self, state: &mut H) { + self.font_file_data.len().hash(state); + self.font_file_data.keys().for_each(|font| font.hash(state)); + } +} + +impl PartialEq for FontCache { + fn eq(&self, other: &Self) -> bool { + if self.font_file_data.len() != other.font_file_data.len() { + return false; + } + self.font_file_data.keys().all(|font| other.font_file_data.contains_key(font)) } } @@ -70,26 +121,8 @@ impl FontCache { } /// Insert a new font into the cache - pub fn insert(&mut self, font: Font, perview_url: String, data: Vec) { + pub fn insert(&mut self, font: Font, data: Vec) { self.font_file_data.insert(font.clone(), data); - self.preview_urls.insert(font, perview_url); - } - - /// Gets the preview URL for showing in text field when live editing - pub fn get_preview_url(&self, font: &Font) -> Option<&String> { - self.preview_urls.get(font) - } -} - -impl std::hash::Hash for FontCache { - fn hash(&self, state: &mut H) { - self.preview_urls.len().hash(state); - self.preview_urls.iter().for_each(|(font, url)| { - font.hash(state); - url.hash(state) - }); - self.font_file_data.len().hash(state); - self.font_file_data.keys().for_each(|font| font.hash(state)); } } From 792f2629b628308c76371745ea5cb55baf14c559 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 19 Dec 2025 21:24:55 -0800 Subject: [PATCH 3/3] Fix Text tool font choice style turning to "-" on font that doesn't support previous style --- .../document/node_graph/node_properties.rs | 7 +- .../src/messages/portfolio/utility_types.rs | 8 ++ .../messages/tool/tool_messages/text_tool.rs | 120 ++++++------------ 3 files changed, 51 insertions(+), 84 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 71e8e00089..2369dc7847 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -789,12 +789,7 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec) -> String { + let name = family.into().replace(' ', "+"); + let italic = if self.italic { "ital," } else { "" }; + let weight = self.weight; + format!("https://fonts.googleapis.com/css2?display=swap&family={name}:{italic}wght@{weight}&text={name}") + } } #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)] diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index b22393f6af..4bc39f2082 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -33,8 +33,7 @@ pub struct TextOptions { font_size: f64, line_height_ratio: f64, character_spacing: f64, - font_name: String, - font_style: String, + font: Font, fill: ToolColorOptions, tilt: f64, align: TextAlign, @@ -46,8 +45,7 @@ impl Default for TextOptions { font_size: 24., line_height_ratio: 1.2, character_spacing: 0., - font_name: graphene_std::consts::DEFAULT_FONT_FAMILY.into(), - font_style: graphene_std::consts::DEFAULT_FONT_STYLE.into(), + font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()), fill: ToolColorOptions::new_primary(), tilt: 0., align: TextAlign::default(), @@ -80,7 +78,7 @@ pub enum TextToolMessage { pub enum TextOptionsUpdate { FillColor(Option), FillColorType(ToolColorType), - Font { family: String, style: String }, + Font { font: Font }, FontSize(f64), LineHeightRatio(f64), Align(TextAlign), @@ -100,49 +98,39 @@ impl ToolMetadata for TextTool { } fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec { + fn update_options(font: Font, commit_style: Option) -> impl Fn(&()) -> Message + Clone { + let mut font = font; + if let Some(style) = commit_style { + font.font_style = style; + } + + move |_| { + TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { font: font.clone() }, + } + .into() + } + } + let font = DropdownInput::new(vec![ font_catalog .0 .iter() .map(|family| { + let font = Font::new(family.name.clone(), tool.options.font.font_style.clone()); + let commit_style = font_catalog.find_font_style_in_catalog(&tool.options.font).map(|style| style.to_named_style()); + let update = update_options(font.clone(), None); + let commit = update_options(font, commit_style); + MenuListEntry::new(family.name.clone()) .label(family.name.clone()) - .font({ - // Get the URL for the stylesheet of a subsetted font preview for the font style closest to weight 400 - let preview_name = family.name.replace(' ', "+"); - let preview_weight = family.closest_style(400, false).weight; - format!("https://fonts.googleapis.com/css2?display=swap&family={preview_name}:wght@{preview_weight}&text={preview_name}") - }) - .on_update({ - let family = family.name.clone(); - let style = tool.options.font_style.clone(); - move |_| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: family.clone(), - style: style.clone(), - }, - } - .into() - } - }) - .on_commit({ - let family = family.name.clone(); - let style = tool.options.font_style.clone(); - move |_| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { - family: family.clone(), - style: style.clone(), - }, - } - .into() - } - }) + .font(family.closest_style(400, false).preview_url(&family.name)) + .on_update(update) + .on_commit(commit) }) .collect::>(), ]) - .selected_index(font_catalog.0.iter().position(|family| family.name == tool.options.font_name).map(|i| i as u32)) + .selected_index(font_catalog.0.iter().position(|family| family.name == tool.options.font.font_family).map(|i| i as u32)) .virtual_scrolling(true) .widget_instance(); @@ -150,42 +138,17 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec Vec MessageHandler> for Text return; }; match options { - TextOptionsUpdate::Font { family, style } => { - self.options.font_name = family; - self.options.font_style = style; + TextOptionsUpdate::Font { font } => { + self.options.font = font; } TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size, TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio, @@ -930,7 +894,7 @@ impl Fsm for TextToolFsmState { tilt: tool_options.tilt, align: tool_options.align, }, - font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()), + font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), }; tool_data.new_text(document, editing_text, font_cache, responses); @@ -956,7 +920,7 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Ready } (TextToolFsmState::Editing, TextToolMessage::RefreshEditingFontData) => { - let font = Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()); + let font = Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()); responses.add(FrontendMessage::DisplayEditableTextboxUpdateFontData { font_data: font_cache.get(&font).map(|(data, _)| data.clone()).unwrap_or_default(), });