Font selection for text layers (#585)

* Add font dropdown

* Add fonts

* Font tool options

* Fix tests

* Replace http with https

* Add variant selection

* Do not embed default font

* Use proxied font list API

* Change default font to Merriweather

* Remove outdated comment

* Specify font once & load font into foreignobject

* Fix tests

* Rename variant to font_style

* Change TextAreaInput to use FieldInput (WIP, breaks functionality)

* Fix textarea functionality

* Fix types

* Add weight name mapping

* Change labeling of "Italic"

* Remove commented HTML node

* Rename font "name" to "font_family" and "file" "font_file"

* Fix errors

* Fix fmt

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-04-21 09:50:44 +01:00 committed by Keavon Chambers
parent e01f0081a9
commit a26679b96c
36 changed files with 1029 additions and 253 deletions

View File

@ -64,8 +64,8 @@ impl Dispatcher {
#[remain::unsorted]
NoOp => {}
Frontend(message) => {
// Image data should be immediatly handled
if let FrontendMessage::UpdateImageData { .. } = message {
// Image and font loading should be immediately handled
if let FrontendMessage::UpdateImageData { .. } | FrontendMessage::TriggerFontLoad { .. } = message {
self.responses.push(message);
return;
}

View File

@ -1 +1 @@
{"graphene_document":{"root":{"visible":true,"name":null,"data":{"Folder":{"next_assignment_id":919378319526168453,"layer_ids":[919378319526168452],"layers":[{"visible":true,"name":null,"data":{"Shape":{"path":[{"MoveTo":{"x":0.0,"y":0.0}},{"LineTo":{"x":1.0,"y":0.0}},{"LineTo":{"x":1.0,"y":1.0}},{"LineTo":{"x":0.0,"y":1.0}},"ClosePath"],"style":{"stroke":null,"fill":{"Solid":{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}}},"render_index":1,"closed":true}},"transform":{"matrix2":[303.890625,0.0,-0.0,362.10546875],"translation":[-148.83984375,-235.8828125]},"blend_mode":"Normal","opacity":1.0}]}},"transform":{"matrix2":[1.0,0.0,0.0,1.0],"translation":[259.88359375,366.9]},"blend_mode":"Normal","opacity":1.0}},"saved_document_identifier":0,"name":"Untitled Document","layer_metadata":[[[919378319526168452],{"selected":true,"expanded":false}],[[],{"selected":false,"expanded":true}]],"layer_range_selection_reference":[919378319526168452],"movement_handler":{"pan":[-118.8,-45.60000000000001],"panning":false,"snap_tilt":false,"snap_tilt_released":false,"tilt":0.0,"tilting":false,"zoom":1.0,"zooming":false,"snap_zoom":false,"mouse_position":[0.0,0.0]},"artboard_message_handler":{"artboards_graphene_document":{"root":{"visible":true,"name":null,"data":{"Folder":{"next_assignment_id":0,"layer_ids":[],"layers":[]}},"transform":{"matrix2":[1.0,0.0,0.0,1.0],"translation":[259.88359375,366.9]},"blend_mode":"Normal","opacity":1.0}},"artboard_ids":[]},"properties_panel_message_handler":{"active_selection":[[919378319526168452],"Artwork"]},"overlays_visible":true,"snapping_enabled":true,"view_mode":"Normal","version":"0.0.5"}
{"graphene_document":{"font_cache": {"data":{},"default":null}, "root":{"visible":true,"name":null,"data":{"Folder":{"next_assignment_id":919378319526168453,"layer_ids":[919378319526168452],"layers":[{"visible":true,"name":null,"data":{"Shape":{"path":[{"MoveTo":{"x":0.0,"y":0.0}},{"LineTo":{"x":1.0,"y":0.0}},{"LineTo":{"x":1.0,"y":1.0}},{"LineTo":{"x":0.0,"y":1.0}},"ClosePath"],"style":{"stroke":null,"fill":{"Solid":{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}}},"render_index":1,"closed":true}},"transform":{"matrix2":[303.890625,0.0,-0.0,362.10546875],"translation":[-148.83984375,-235.8828125]},"blend_mode":"Normal","opacity":1.0}]}},"transform":{"matrix2":[1.0,0.0,0.0,1.0],"translation":[259.88359375,366.9]},"blend_mode":"Normal","opacity":1.0}},"saved_document_identifier":0,"name":"Untitled Document","layer_metadata":[[[919378319526168452],{"selected":true,"expanded":false}],[[],{"selected":false,"expanded":true}]],"layer_range_selection_reference":[919378319526168452],"movement_handler":{"pan":[-118.8,-45.60000000000001],"panning":false,"snap_tilt":false,"snap_tilt_released":false,"tilt":0.0,"tilting":false,"zoom":1.0,"zooming":false,"snap_zoom":false,"mouse_position":[0.0,0.0]},"artboard_message_handler":{"artboards_graphene_document":{"font_cache": {"data": {}}, "root":{"visible":true,"name":null,"data":{"Folder":{"next_assignment_id":0,"layer_ids":[],"layers":[]}},"transform":{"matrix2":[1.0,0.0,0.0,1.0],"translation":[259.88359375,366.9]},"blend_mode":"Normal","opacity":1.0}},"artboard_ids":[]},"properties_panel_message_handler":{"active_selection":[[919378319526168452],"Artwork"]},"overlays_visible":true,"snapping_enabled":true,"view_mode":"Normal","version":"0.0.6"}

View File

@ -57,5 +57,5 @@ pub const FILE_EXPORT_SUFFIX: &str = ".svg";
pub const COLOR_ACCENT: Color = Color::from_unsafe(0x00 as f32 / 255., 0xA8 as f32 / 255., 0xFF as f32 / 255.);
// Document
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.5";
pub const GRAPHITE_DOCUMENT_VERSION: &str = "0.0.6";
pub const VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR: f32 = 1.05;

View File

@ -66,10 +66,18 @@ pub enum DocumentMessage {
FolderChanged {
affected_folder_path: Vec<LayerId>,
},
FontLoaded {
font: String,
data: Vec<u8>,
is_default: bool,
},
GroupSelectedLayers,
LayerChanged {
affected_layer_path: Vec<LayerId>,
},
LoadFont {
font: String,
},
MoveSelectedLayersTo {
folder_path: Vec<LayerId>,
insert_index: isize,

View File

@ -162,7 +162,13 @@ impl DocumentMessageHandler {
// TODO: Create VectorManipulatorShape when creating a kurbo shape as a stopgap, rather than on each new selection
match &layer.ok()?.data {
LayerDataType::Shape(shape) => Some(VectorShape::new(path_to_shape.to_vec(), viewport_transform, &shape.path, shape.closed, responses)),
LayerDataType::Text(text) => Some(VectorShape::new(path_to_shape.to_vec(), viewport_transform, &text.to_bez_path_nonmut(), true, responses)),
LayerDataType::Text(text) => Some(VectorShape::new(
path_to_shape.to_vec(),
viewport_transform,
&text.to_bez_path_nonmut(&self.graphene_document.font_cache),
true,
responses,
)),
_ => None,
}
});
@ -421,7 +427,7 @@ impl DocumentMessageHandler {
.get_mut(&path)
.ok_or_else(|| EditorError::Document(format!("Could not get layer metadata for {:?}", path)))?;
let layer = self.graphene_document.layer(&path)?;
let entry = layer_panel_entry(&data, self.graphene_document.multiply_transforms(&path)?, layer, path);
let entry = layer_panel_entry(&data, self.graphene_document.multiply_transforms(&path)?, layer, path, &self.graphene_document.font_cache);
Ok(entry)
}
@ -442,7 +448,7 @@ impl DocumentMessageHandler {
.ok()?;
let layer = self.graphene_document.layer(path).ok()?;
Some(layer_panel_entry(layer_metadata, transform, layer, path.to_vec()))
Some(layer_panel_entry(layer_metadata, transform, layer, path.to_vec(), &self.graphene_document.font_cache))
}
/// When working with an insert index, deleting the layers may cause the insert index to point to a different location (if the layer being deleted was located before the insert index).
@ -477,12 +483,12 @@ impl DocumentMessageHandler {
/// Creates the blob URLs for the image data in the document
pub fn load_image_data(&self, responses: &mut VecDeque<Message>, root: &LayerDataType, mut path: Vec<LayerId>) {
let mut image_data = Vec::new();
fn walk_layers(data: &LayerDataType, path: &mut Vec<LayerId>, responses: &mut VecDeque<Message>, image_data: &mut Vec<FrontendImageData>) {
fn walk_layers(data: &LayerDataType, path: &mut Vec<LayerId>, image_data: &mut Vec<FrontendImageData>) {
match data {
LayerDataType::Folder(f) => {
for (id, layer) in f.layer_ids.iter().zip(f.layers().iter()) {
path.push(*id);
walk_layers(&layer.data, path, responses, image_data);
walk_layers(&layer.data, path, image_data);
path.pop();
}
}
@ -495,11 +501,17 @@ impl DocumentMessageHandler {
}
}
walk_layers(root, &mut path, responses, &mut image_data);
walk_layers(root, &mut path, &mut image_data);
if !image_data.is_empty() {
responses.push_front(FrontendMessage::UpdateImageData { image_data }.into());
}
}
pub fn load_default_font(&self, responses: &mut VecDeque<Message>) {
if !self.graphene_document.font_cache.has_default() {
responses.push_back(FrontendMessage::TriggerDefaultFontLoad.into())
}
}
}
impl PropertyHolder for DocumentMessageHandler {
@ -897,6 +909,10 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
let affected_layer_path = affected_folder_path;
responses.extend([LayerChanged { affected_layer_path }.into(), DocumentStructureChanged.into()]);
}
FontLoaded { font, data, is_default } => {
self.graphene_document.font_cache.insert(font, data, is_default);
responses.push_back(DocumentMessage::DirtyRenderDocument.into());
}
GroupSelectedLayers => {
let mut new_folder_path = self.graphene_document.shallowest_common_folder(self.selected_layers()).unwrap_or(&[]).to_vec();
@ -932,6 +948,11 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
}
responses.push_back(PropertiesPanelMessage::CheckSelectedWasUpdated { path: affected_layer_path }.into());
}
LoadFont { font } => {
if !self.graphene_document.font_cache.loaded_font(&font) {
responses.push_front(FrontendMessage::TriggerFontLoad { font }.into());
}
}
MoveSelectedLayersTo {
folder_path,
insert_index,
@ -1232,7 +1253,7 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
let text = self.graphene_document.layer(&path).unwrap().as_text().unwrap();
responses.push_back(DocumentOperation::SetTextEditability { path, editable }.into());
if editable {
let color = if let Fill::Solid(solid_color) = text.style.fill() { *solid_color } else { Color::BLACK };
let color = if let Fill::Solid(solid_color) = text.path_style.fill() { *solid_color } else { Color::BLACK };
responses.push_back(
FrontendMessage::DisplayEditableTextbox {
text: text.text.clone(),

View File

@ -1,3 +1,4 @@
use graphene::document::FontCache;
use graphene::layers::blend_mode::BlendMode;
use graphene::layers::layer_info::{Layer, LayerData, LayerDataType};
use graphene::layers::style::ViewMode;
@ -20,14 +21,14 @@ impl LayerMetadata {
}
}
pub fn layer_panel_entry(layer_metadata: &LayerMetadata, transform: DAffine2, layer: &Layer, path: Vec<LayerId>) -> LayerPanelEntry {
pub fn layer_panel_entry(layer_metadata: &LayerMetadata, transform: DAffine2, layer: &Layer, path: Vec<LayerId>, font_cache: &FontCache) -> LayerPanelEntry {
let name = layer.name.clone().unwrap_or_else(|| String::from(""));
let arr = layer.data.bounding_box(transform).unwrap_or([DVec2::ZERO, DVec2::ZERO]);
let arr = layer.data.bounding_box(transform, font_cache).unwrap_or([DVec2::ZERO, DVec2::ZERO]);
let arr = arr.iter().map(|x| (*x).into()).collect::<Vec<(f64, f64)>>();
let mut thumbnail = String::new();
let mut svg_defs = String::new();
layer.data.clone().render(&mut thumbnail, &mut svg_defs, &mut vec![transform], ViewMode::Normal);
layer.data.clone().render(&mut thumbnail, &mut svg_defs, &mut vec![transform], ViewMode::Normal, font_cache);
let transform = transform.to_cols_array().iter().map(ToString::to_string).collect::<Vec<_>>().join(",");
let thumbnail = if let [(x_min, y_min), (x_max, y_max)] = arr.as_slice() {
format!(

View File

@ -52,7 +52,7 @@ impl PortfolioMessageHandler {
name
}
// TODO Fix how this doesn't preserve tab order upon loading new document from file>load
// TODO Fix how this doesn't preserve tab order upon loading new document from *File > Load*
fn load_document(&mut self, new_document: DocumentMessageHandler, document_id: u64, replace_first_empty: bool, responses: &mut VecDeque<Message>) {
// Special case when loading a document on an empty page
if replace_first_empty && self.active_document().is_unmodified_default() {
@ -79,6 +79,7 @@ impl PortfolioMessageHandler {
);
new_document.load_image_data(responses, &new_document.graphene_document.root.data, Vec::new());
new_document.load_default_font(responses);
self.documents.insert(document_id, new_document);

View File

@ -9,15 +9,40 @@ use serde::{Deserialize, Serialize};
#[impl_message(Message, DocumentMessage, PropertiesPanel)]
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum PropertiesPanelMessage {
CheckSelectedWasDeleted { path: Vec<LayerId> },
CheckSelectedWasUpdated { path: Vec<LayerId> },
CheckSelectedWasDeleted {
path: Vec<LayerId>,
},
CheckSelectedWasUpdated {
path: Vec<LayerId>,
},
ClearSelection,
ModifyFill { fill: Fill },
ModifyName { name: String },
ModifyStroke { stroke: Stroke },
ModifyTransform { value: f64, transform_op: TransformOp },
ModifyFill {
fill: Fill,
},
ModifyFont {
font_family: String,
font_style: String,
font_file: Option<String>,
size: f64,
},
ModifyName {
name: String,
},
ModifyStroke {
stroke: Stroke,
},
ModifyText {
new_text: String,
},
ModifyTransform {
value: f64,
transform_op: TransformOp,
},
ResendActiveProperties,
SetActiveLayers { paths: Vec<Vec<LayerId>>, document: TargetDocument },
SetActiveLayers {
paths: Vec<Vec<LayerId>>,
document: TargetDocument,
},
}
#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]

View File

@ -3,15 +3,16 @@ use super::utility_types::TargetDocument;
use crate::document::properties_panel_message::TransformOp;
use crate::layout::layout_message::LayoutTarget;
use crate::layout::widgets::{
ColorInput, IconLabel, LayoutRow, NumberInput, PopoverButton, RadioEntryData, RadioInput, Separator, SeparatorDirection, SeparatorType, TextInput, TextLabel, Widget, WidgetCallback, WidgetHolder,
WidgetLayout,
ColorInput, FontInput, IconLabel, LayoutRow, NumberInput, PopoverButton, RadioEntryData, RadioInput, Separator, SeparatorDirection, SeparatorType, TextAreaInput, TextInput, TextLabel, Widget,
WidgetCallback, WidgetHolder, WidgetLayout,
};
use crate::message_prelude::*;
use graphene::color::Color;
use graphene::document::Document as GrapheneDocument;
use graphene::document::{Document as GrapheneDocument, FontCache};
use graphene::layers::layer_info::{Layer, LayerDataType};
use graphene::layers::style::{Fill, LineCap, LineJoin, Stroke};
use graphene::layers::text_layer::TextLayer;
use graphene::{LayerId, Operation};
use glam::{DAffine2, DVec2};
@ -148,6 +149,23 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
.into(),
);
}
ModifyFont {
font_family,
font_style,
font_file,
size,
} => {
let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
responses.push_back(self.create_document_operation(Operation::ModifyFont {
path,
font_family,
font_style,
font_file,
size,
}));
responses.push_back(ResendActiveProperties.into());
}
ModifyTransform { value, transform_op } => {
let (path, target_document) = self.active_selection.as_ref().expect("Received update for properties panel with no active layer");
let layer = get_document(*target_document).layer(path).unwrap();
@ -162,8 +180,8 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
};
let scale = match transform_op {
Width => layer.bounding_transform().scale_x() / layer.transform.scale_x(),
Height => layer.bounding_transform().scale_y() / layer.transform.scale_y(),
Width => layer.bounding_transform(&get_document(*target_document).font_cache).scale_x() / layer.transform.scale_x(),
Height => layer.bounding_transform(&get_document(*target_document).font_cache).scale_y() / layer.transform.scale_y(),
_ => 1.,
};
@ -184,6 +202,10 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
responses.push_back(self.create_document_operation(Operation::SetLayerStroke { path, stroke }))
}
ModifyText { new_text } => {
let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
responses.push_back(Operation::SetTextContent { path, new_text }.into())
}
CheckSelectedWasUpdated { path } => {
if self.matches_selected(&path) {
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into())
@ -212,8 +234,8 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
let (path, target_document) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
let layer = get_document(target_document).layer(&path).unwrap();
match target_document {
TargetDocument::Artboard => register_artboard_layer_properties(layer, responses),
TargetDocument::Artwork => register_artwork_layer_properties(layer, responses),
TargetDocument::Artboard => register_artboard_layer_properties(layer, responses, &get_document(target_document).font_cache),
TargetDocument::Artwork => register_artwork_layer_properties(layer, responses, &get_document(target_document).font_cache),
}
}
}
@ -224,7 +246,7 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
}
}
fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>) {
fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>, font_cache: &FontCache) {
let options_bar = vec![LayoutRow::Row {
widgets: vec![
WidgetHolder::new(Widget::IconLabel(IconLabel {
@ -326,7 +348,7 @@ fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque<Me
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: layer.bounding_transform().scale_x(),
value: layer.bounding_transform(font_cache).scale_x(),
label: "W".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| {
@ -343,7 +365,7 @@ fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque<Me
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: layer.bounding_transform().scale_y(),
value: layer.bounding_transform(font_cache).scale_y(),
label: "H".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| {
@ -405,7 +427,7 @@ fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque<Me
);
}
fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>) {
fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>, font_cache: &FontCache) {
let options_bar = vec![LayoutRow::Row {
widgets: vec![
match &layer.data {
@ -456,20 +478,21 @@ fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque<Mes
let properties_body = match &layer.data {
LayerDataType::Shape(shape) => {
if let Some(fill_layout) = node_section_fill(shape.style.fill()) {
vec![node_section_transform(layer), fill_layout, node_section_stroke(&shape.style.stroke().unwrap_or_default())]
vec![node_section_transform(layer, font_cache), fill_layout, node_section_stroke(&shape.style.stroke().unwrap_or_default())]
} else {
vec![node_section_transform(layer), node_section_stroke(&shape.style.stroke().unwrap_or_default())]
vec![node_section_transform(layer, font_cache), node_section_stroke(&shape.style.stroke().unwrap_or_default())]
}
}
LayerDataType::Text(text) => {
vec![
node_section_transform(layer),
node_section_fill(text.style.fill()).expect("Text should have fill"),
node_section_stroke(&text.style.stroke().unwrap_or_default()),
node_section_transform(layer, font_cache),
node_section_font(text),
node_section_fill(text.path_style.fill()).expect("Text should have fill"),
node_section_stroke(&text.path_style.stroke().unwrap_or_default()),
]
}
LayerDataType::Image(_) => {
vec![node_section_transform(layer)]
vec![node_section_transform(layer, font_cache)]
}
_ => {
vec![]
@ -492,7 +515,7 @@ fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque<Mes
);
}
fn node_section_transform(layer: &Layer) -> LayoutRow {
fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutRow {
LayoutRow::Section {
name: "Transform".into(),
layout: vec![
@ -616,7 +639,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: layer.bounding_transform().scale_x(),
value: layer.bounding_transform(font_cache).scale_x(),
label: "W".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| {
@ -633,7 +656,7 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: layer.bounding_transform().scale_y(),
value: layer.bounding_transform(font_cache).scale_y(),
label: "H".into(),
unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| {
@ -651,6 +674,115 @@ fn node_section_transform(layer: &Layer) -> LayoutRow {
}
}
fn node_section_font(layer: &TextLayer) -> LayoutRow {
let font_family = layer.font_family.clone();
let font_style = layer.font_style.clone();
let font_file = layer.font_file.clone();
let size = layer.size;
LayoutRow::Section {
name: "Font".into(),
layout: vec![
LayoutRow::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Text".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextAreaInput(TextAreaInput {
value: layer.text.clone(),
on_update: WidgetCallback::new(|text_area: &TextAreaInput| PropertiesPanelMessage::ModifyText { new_text: text_area.value.clone() }.into()),
})),
],
},
LayoutRow::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Font".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::FontInput(FontInput {
is_style_picker: false,
font_family: layer.font_family.clone(),
font_style: layer.font_style.clone(),
font_file: String::new(),
on_update: WidgetCallback::new(move |font_input: &FontInput| {
PropertiesPanelMessage::ModifyFont {
font_family: font_input.font_family.clone(),
font_style: font_input.font_style.clone(),
font_file: Some(font_input.font_file.clone()),
size,
}
.into()
}),
})),
],
},
LayoutRow::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Style".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::FontInput(FontInput {
is_style_picker: true,
font_family: layer.font_family.clone(),
font_style: layer.font_style.clone(),
font_file: String::new(),
on_update: WidgetCallback::new(move |font_input: &FontInput| {
PropertiesPanelMessage::ModifyFont {
font_family: font_input.font_family.clone(),
font_style: font_input.font_style.clone(),
font_file: Some(font_input.font_file.clone()),
size,
}
.into()
}),
})),
],
},
LayoutRow::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Size".into(),
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: layer.size,
min: Some(1.),
unit: " px".into(),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyFont {
font_family: font_family.clone(),
font_style: font_style.clone(),
font_file: font_file.clone(),
size: number_input.value,
}
.into()
}),
..Default::default()
})),
],
},
],
}
}
fn node_section_fill(fill: &Fill) -> Option<LayoutRow> {
match fill {
Fill::Solid(_) | Fill::None => Some(LayoutRow::Section {

View File

@ -45,7 +45,7 @@ impl MessageHandler<TransformLayerMessage, (&mut HashMap<Vec<LayerId>, LayerMeta
selected.revert_operation();
typing.clear();
} else {
*selected.pivot = selected.calculate_pivot();
*selected.pivot = selected.calculate_pivot(&document.font_cache);
}
*mouse_position = ipp.mouse.position;
@ -128,7 +128,7 @@ impl MessageHandler<TransformLayerMessage, (&mut HashMap<Vec<LayerId>, LayerMeta
self.transform_operation.apply_transform_operation(&mut selected, self.snap);
}
TransformOperation::Rotating(rotation) => {
let selected_pivot = selected.calculate_pivot();
let selected_pivot = selected.calculate_pivot(&document.font_cache);
let angle = {
let start_offset = self.mouse_position - selected_pivot;
let end_offset = ipp.mouse.position - selected_pivot;

View File

@ -1,7 +1,7 @@
use crate::consts::{ROTATE_SNAP_ANGLE, SCALE_SNAP_INTERVAL};
use crate::message_prelude::*;
use graphene::document::Document;
use graphene::document::{Document, FontCache};
use graphene::Operation as DocumentOperation;
use glam::{DAffine2, DVec2};
@ -210,7 +210,7 @@ impl<'a> Selected<'a> {
}
}
pub fn calculate_pivot(&mut self) -> DVec2 {
pub fn calculate_pivot(&mut self, font_cache: &FontCache) -> DVec2 {
let xy_summation = self
.selected
.iter()
@ -221,7 +221,7 @@ impl<'a> Selected<'a> {
.document
.layer(path)
.unwrap()
.aabounding_box_for_transform(multiplied_transform)
.aabounding_box_for_transform(multiplied_transform, font_cache)
.unwrap_or([multiplied_transform.translation; 2]);
(bounds[0] + bounds[1]) / 2.

View File

@ -24,8 +24,10 @@ pub enum FrontendMessage {
DisplayRemoveEditableTextbox,
// Trigger prefix: cause a browser API to do something
TriggerDefaultFontLoad,
TriggerFileDownload { document: String, name: String },
TriggerFileUpload,
TriggerFontLoad { font: String },
TriggerIndexedDbRemoveDocument { document_id: u64 },
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
TriggerTextCommit,

View File

@ -91,12 +91,36 @@ impl MessageHandler<LayoutMessage, ()> for LayoutMessageHandler {
let callback_message = (text_input.on_update.callback)(text_input);
responses.push_back(callback_message);
}
Widget::TextAreaInput(text_area_input) => {
let update_value = value.as_str().expect("TextAreaInput update was not of type: string");
text_area_input.value = update_value.into();
let callback_message = (text_area_input.on_update.callback)(text_area_input);
responses.push_back(callback_message);
}
Widget::ColorInput(color_input) => {
let update_value = value.as_str().map(String::from);
color_input.value = update_value;
let callback_message = (color_input.on_update.callback)(color_input);
responses.push_back(callback_message);
}
Widget::FontInput(font_input) => {
let update_value = value.as_object().expect("FontInput update was not of type: object");
let font_family_value = update_value.get("fontFamily").expect("FontInput update does not have a fontFamily");
let font_style_value = update_value.get("fontStyle").expect("FontInput update does not have a fontStyle");
let font_file_value = update_value.get("fontFile").expect("FontInput update does not have a fontFile");
let font_family = font_family_value.as_str().expect("FontInput update fontFamily was not of type: string");
let font_style = font_style_value.as_str().expect("FontInput update fontStyle was not of type: string");
let font_file = font_file_value.as_str().expect("FontInput update fontFile was not of type: string");
font_input.font_family = font_family.into();
font_input.font_style = font_style.into();
font_input.font_file = font_file.into();
responses.push_back(DocumentMessage::LoadFont { font: font_file.into() }.into());
let callback_message = (font_input.on_update.callback)(font_input);
responses.push_back(callback_message);
}
Widget::TextLabel(_) => {}
};
self.send_layout(layout_target, responses);

View File

@ -151,6 +151,7 @@ impl<T> Default for WidgetCallback<T> {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Widget {
ColorInput(ColorInput),
FontInput(FontInput),
IconButton(IconButton),
IconLabel(IconLabel),
NumberInput(NumberInput),
@ -158,6 +159,7 @@ pub enum Widget {
PopoverButton(PopoverButton),
RadioInput(RadioInput),
Separator(Separator),
TextAreaInput(TextAreaInput),
TextInput(TextInput),
TextLabel(TextLabel),
}
@ -200,6 +202,15 @@ pub struct TextInput {
pub on_update: WidgetCallback<TextInput>,
}
#[derive(Clone, Serialize, Deserialize, Derivative)]
#[derivative(Debug, PartialEq, Default)]
pub struct TextAreaInput {
pub value: String,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<TextAreaInput>,
}
#[derive(Clone, Serialize, Deserialize, Derivative)]
#[derivative(Debug, PartialEq, Default)]
pub struct ColorInput {
@ -212,6 +223,22 @@ pub struct ColorInput {
pub can_set_transparent: bool,
}
#[derive(Clone, Serialize, Deserialize, Derivative)]
#[derivative(Debug, PartialEq, Default)]
pub struct FontInput {
#[serde(rename = "isStyle")]
pub is_style_picker: bool,
#[serde(rename = "fontFamily")]
pub font_family: String,
#[serde(rename = "fontStyle")]
pub font_style: String,
#[serde(rename = "fontFile")]
pub font_file: String,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<FontInput>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum NumberInputIncrementBehavior {
Add,

View File

@ -81,7 +81,7 @@ impl Default for GradientToolFsmState {
/// Computes the transform from gradient space to layer space (where gradient space is 0..1 in layer space)
fn gradient_space_transform(path: &[LayerId], layer: &Layer, document: &DocumentMessageHandler) -> DAffine2 {
let bounds = layer.aabounding_box_for_transform(DAffine2::IDENTITY).unwrap();
let bounds = layer.aabounding_box_for_transform(DAffine2::IDENTITY, &document.graphene_document.font_cache).unwrap();
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
let multiplied = document.graphene_document.multiply_transforms(path).unwrap();

View File

@ -407,7 +407,7 @@ impl Fsm for SelectToolFsmState {
let selected = selected.iter().collect::<Vec<_>>();
let mut selected = Selected::new(&mut bounds.original_transforms, &mut bounds.pivot, &selected, responses, &document.graphene_document);
*selected.pivot = selected.calculate_pivot();
*selected.pivot = selected.calculate_pivot(&document.graphene_document.font_cache);
}
data.layers_dragging = selected;

View File

@ -3,12 +3,14 @@ use crate::document::DocumentMessageHandler;
use crate::frontend::utility_types::MouseCursorIcon;
use crate::input::keyboard::{Key, MouseMotion};
use crate::input::InputPreprocessorMessageHandler;
use crate::layout::widgets::{LayoutRow, NumberInput, PropertyHolder, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
use crate::layout::layout_message::LayoutTarget;
use crate::layout::widgets::{FontInput, LayoutRow, NumberInput, PropertyHolder, Separator, SeparatorDirection, SeparatorType, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
use crate::message_prelude::*;
use crate::misc::{HintData, HintGroup, HintInfo, KeysGroup};
use crate::viewport_tools::tool::{DocumentToolData, Fsm, ToolActionHandlerData};
use glam::{DAffine2, DVec2};
use graphene::document::FontCache;
use graphene::intersection::Quad;
use graphene::layers::style::{self, Fill, Stroke};
use graphene::Operation;
@ -24,11 +26,19 @@ pub struct TextTool {
pub struct TextOptions {
font_size: u32,
font_name: String,
font_style: String,
font_file: Option<String>,
}
impl Default for TextOptions {
fn default() -> Self {
Self { font_size: 14 }
Self {
font_size: 24,
font_name: "Merriweather".into(),
font_style: "Normal (400)".into(),
font_file: None,
}
}
}
@ -58,21 +68,60 @@ pub enum TextMessage {
#[remain::sorted]
#[derive(PartialEq, Clone, Debug, Hash, Serialize, Deserialize)]
pub enum TextOptionsUpdate {
Font { family: String, style: String, file: String },
FontSize(u32),
}
impl PropertyHolder for TextTool {
fn properties(&self) -> WidgetLayout {
WidgetLayout::new(vec![LayoutRow::Row {
widgets: vec![WidgetHolder::new(Widget::NumberInput(NumberInput {
unit: " px".into(),
label: "Font Size".into(),
value: self.options.font_size as f64,
is_integer: true,
min: Some(1.),
on_update: WidgetCallback::new(|number_input: &NumberInput| TextMessage::UpdateOptions(TextOptionsUpdate::FontSize(number_input.value as u32)).into()),
..NumberInput::default()
}))],
widgets: vec![
WidgetHolder::new(Widget::FontInput(FontInput {
is_style_picker: false,
font_family: self.options.font_name.clone(),
font_style: self.options.font_style.clone(),
on_update: WidgetCallback::new(|font_input: &FontInput| {
TextMessage::UpdateOptions(TextOptionsUpdate::Font {
family: font_input.font_family.clone(),
style: font_input.font_style.clone(),
file: font_input.font_file.clone(),
})
.into()
}),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
direction: SeparatorDirection::Horizontal,
separator_type: SeparatorType::Related,
})),
WidgetHolder::new(Widget::FontInput(FontInput {
is_style_picker: true,
font_family: self.options.font_name.clone(),
font_style: self.options.font_style.clone(),
on_update: WidgetCallback::new(|font_input: &FontInput| {
TextMessage::UpdateOptions(TextOptionsUpdate::Font {
family: font_input.font_family.clone(),
style: font_input.font_style.clone(),
file: font_input.font_file.clone(),
})
.into()
}),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
direction: SeparatorDirection::Horizontal,
separator_type: SeparatorType::Related,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
unit: " px".into(),
label: "Size".into(),
value: self.options.font_size as f64,
is_integer: true,
min: Some(1.),
on_update: WidgetCallback::new(|number_input: &NumberInput| TextMessage::UpdateOptions(TextOptionsUpdate::FontSize(number_input.value as u32)).into()),
..NumberInput::default()
})),
],
}])
}
}
@ -91,6 +140,13 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for TextTool {
if let ToolMessage::Text(TextMessage::UpdateOptions(action)) = action {
match action {
TextOptionsUpdate::Font { family, style, file } => {
self.options.font_name = family;
self.options.font_style = style;
self.options.font_file = Some(file);
self.register_properties(responses, LayoutTarget::ToolOptions);
}
TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size,
}
return;
@ -155,25 +211,33 @@ fn resize_overlays(overlays: &mut Vec<Vec<LayerId>>, responses: &mut VecDeque<Me
}
}
fn update_overlays(document: &DocumentMessageHandler, data: &mut TextToolData, responses: &mut VecDeque<Message>) {
fn update_overlays(document: &DocumentMessageHandler, data: &mut TextToolData, responses: &mut VecDeque<Message>, font_cache: &FontCache) {
let visible_text_layers = document.selected_visible_text_layers().collect::<Vec<_>>();
resize_overlays(&mut data.overlays, responses, visible_text_layers.len());
for (layer_path, overlay_path) in visible_text_layers.into_iter().zip(&data.overlays) {
let bounds = document
.graphene_document
.layer(layer_path)
.unwrap()
.aabounding_box_for_transform(document.graphene_document.multiply_transforms(layer_path).unwrap())
.unwrap();
let bounds = visible_text_layers
.into_iter()
.zip(&data.overlays)
.filter_map(|(layer_path, overlay_path)| {
document
.graphene_document
.layer(layer_path)
.unwrap()
.aabounding_box_for_transform(document.graphene_document.multiply_transforms(layer_path).unwrap(), font_cache)
.map(|bounds| (bounds, overlay_path))
})
.collect::<Vec<_>>();
let new_len = bounds.len();
for (bounds, overlay_path) in bounds {
let operation = Operation::SetLayerTransformInViewport {
path: overlay_path.to_vec(),
transform: transform_from_box(bounds[0], bounds[1]),
};
responses.push_back(DocumentMessage::Overlays(operation.into()).into());
}
resize_overlays(&mut data.overlays, responses, new_len);
}
impl Fsm for TextToolFsmState {
@ -196,7 +260,7 @@ impl Fsm for TextToolFsmState {
if let ToolMessage::Text(event) = event {
match (self, event) {
(state, DocumentIsDirty) => {
update_overlays(document, data, responses);
update_overlays(document, data, responses, &document.graphene_document.font_cache);
state
}
@ -244,6 +308,9 @@ impl Fsm for TextToolFsmState {
else if state == TextToolFsmState::Ready {
let transform = DAffine2::from_translation(input.mouse.position).to_cols_array();
let font_size = tool_options.font_size;
let font_name = tool_options.font_name.clone();
let font_style = tool_options.font_style.clone();
let font_file = tool_options.font_file.clone();
data.path = document.get_path_for_new_layer();
responses.push_back(
@ -254,6 +321,9 @@ impl Fsm for TextToolFsmState {
text: r#""#.to_string(),
style: style::PathStyle::new(None, Fill::solid(tool_data.primary_color)),
size: font_size as f64,
font_name,
font_style,
font_file,
}
.into(),
);
@ -329,7 +399,8 @@ impl Fsm for TextToolFsmState {
}
(Editing, UpdateBounds { new_text }) => {
resize_overlays(&mut data.overlays, responses, 1);
let mut path = document.graphene_document.layer(&data.path).unwrap().as_text().unwrap().bounding_box(&new_text).to_path(0.1);
let text = document.graphene_document.layer(&data.path).unwrap().as_text().unwrap();
let mut path = text.bounding_box(&new_text, text.load_face(&document.graphene_document.font_cache)).to_path(0.1);
fn glam_to_kurbo(transform: DAffine2) -> kurbo::Affine {
kurbo::Affine::new(transform.to_cols_array())

View File

@ -158,6 +158,10 @@ img {
background-color: var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-corner {
background: none;
}
}
.scrollable-x.scrollable-y {

View File

@ -287,10 +287,14 @@ import {
TriggerViewportResize,
DisplayRemoveEditableTextbox,
DisplayEditableTextbox,
TriggerFontLoad,
TriggerDefaultFontLoad,
} from "@/dispatcher/js-messages";
import { textInputCleanup } from "@/lifetime/input";
import { loadDefaultFont, setLoadDefaultFontCallback } from "@/utilities/fonts";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
@ -453,6 +457,14 @@ export default defineComponent({
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) this.editor.instance.on_change_text(textInputCleanup(this.textInput.innerText));
});
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoad, (triggerFontLoad) => {
fetch(triggerFontLoad.font)
.then((response) => response.arrayBuffer())
.then((response) => {
this.editor.instance.on_font_load(triggerFontLoad.font, new Uint8Array(response), false);
});
});
this.editor.dispatcher.subscribeJsMessage(TriggerDefaultFontLoad, loadDefaultFont);
this.editor.dispatcher.subscribeJsMessage(TriggerTextCopy, async (triggerTextCopy) => {
// Clipboard API supported?
if (!navigator.clipboard) return;
@ -514,6 +526,7 @@ export default defineComponent({
// TODO(mfish33): Replace with initialization system Issue:#524
// Get initial Document Bar
this.editor.instance.init_document_bar();
setLoadDefaultFontCallback((font: string, data: Uint8Array) => this.editor.instance.on_font_load(font, data, true));
},
data() {
const documentModeEntries: SectionsOfMenuListEntries = [

View File

@ -15,7 +15,13 @@
:incrementCallbackDecrease="() => updateLayout(component.widget_id, 'Decrement')"
/>
<TextInput v-if="component.kind === 'TextInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<TextAreaInput v-if="component.kind === 'TextAreaInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<FontInput
v-if="component.kind === 'FontInput'"
v-bind="component.props"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)"
/>
<IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
<OptionalInput v-if="component.kind === 'OptionalInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<RadioInput v-if="component.kind === 'RadioInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
@ -28,10 +34,24 @@
<style lang="scss">
.widget-row {
height: 32px;
min-height: 32px;
flex: 0 0 auto;
display: flex;
align-items: center;
> * {
--widget-height: 24px;
min-height: var(--widget-height);
line-height: var(--widget-height);
margin: calc((24px - var(--widget-height)) / 2 + 4px) 0;
&.icon-label.size-12 {
--widget-height: 12px;
}
&.icon-label.size-16 {
--widget-height: 16px;
}
}
}
</style>
@ -43,9 +63,11 @@ import { WidgetRow } from "@/dispatcher/js-messages";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
import ColorInput from "@/components/widgets/inputs/ColorInput.vue";
import FontInput from "@/components/widgets/inputs/FontInput.vue";
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import RadioInput from "@/components/widgets/inputs/RadioInput.vue";
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.vue";
import TextInput from "@/components/widgets/inputs/TextInput.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
@ -73,6 +95,8 @@ export default defineComponent({
TextLabel,
IconLabel,
ColorInput,
FontInput,
TextAreaInput,
},
});
</script>

View File

@ -2,6 +2,7 @@
<template>
<LayoutRow class="field-input" :class="{ disabled }">
<input
v-if="!textarea"
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
ref="input"
@ -15,6 +16,22 @@
@keydown.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
/>
<textarea
v-else
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
class="scrollable-y"
data-scrollable-y
ref="input"
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@keydown.ctrl.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
></textarea>
<label v-if="label" :for="`field-input-${id}`">{{ label }}</label>
<slot></slot>
</LayoutRow>
@ -23,7 +40,7 @@
<style lang="scss">
.field-input {
min-width: 80px;
height: 24px;
height: auto;
position: relative;
border-radius: 2px;
background: var(--color-1-nearblack);
@ -43,7 +60,8 @@
cursor: text;
}
input {
input,
textarea {
flex: 1 1 100%;
width: 0;
min-width: 30px;
@ -55,6 +73,9 @@
border: none;
background: none;
color: var(--color-e-nearwhite);
}
input {
text-align: center;
&:not(:focus).has-label {
@ -72,11 +93,20 @@
}
}
textarea {
min-height: calc(18px * 4);
margin: 3px;
padding: 0 5px;
box-sizing: border-box;
resize: vertical;
}
&.disabled {
background: var(--color-2-mildblack);
label,
input {
input,
textarea {
color: var(--color-8-uppergray);
}
}
@ -95,6 +125,7 @@ export default defineComponent({
label: { type: String as PropType<string>, required: false },
spellcheck: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
textarea: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {

View File

@ -0,0 +1,165 @@
<template>
<LayoutRow class="dropdown-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
<span>{{ activeEntry.label }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList
v-model:activeEntry="activeEntry"
@widthChanged="(newWidth: number) => onWidthChanged(newWidth)"
:menuEntries="menuEntries"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
</LayoutRow>
</template>
<style lang="scss">
.dropdown-input {
position: relative;
.dropdown-box {
align-items: center;
white-space: nowrap;
background: var(--color-1-nearblack);
height: 24px;
border-radius: 2px;
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
}
span {
margin: 0;
margin-left: 8px;
flex: 1 1 100%;
}
.dropdown-icon + span {
margin-left: 0;
}
.dropdown-arrow {
margin: 6px 2px;
flex: 0 0 auto;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
span {
color: var(--color-f-white);
}
svg {
fill: var(--color-f-white);
}
}
&.open {
border-radius: 2px 2px 0 0;
}
&.disabled {
background: var(--color-2-mildblack);
span {
color: var(--color-8-uppergray);
}
svg {
fill: var(--color-8-uppergray);
}
}
}
.menu-list .floating-menu-container .floating-menu-content {
max-height: 400px;
}
}
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { fontNames, getFontFile, getFontStyles } from "@/utilities/fonts";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: {
fontFamily: { type: String as PropType<string>, required: true },
fontStyle: { type: String as PropType<string>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
isStyle: { type: Boolean as PropType<boolean>, default: false },
},
data() {
const { menuEntries, activeEntry } = this.updateEntries();
return {
menuEntries,
activeEntry,
minWidth: 0,
};
},
methods: {
clickDropdownBox() {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
},
selectFont(newName: string) {
if (this.isStyle) this.$emit("update:fontStyle", newName);
else this.$emit("update:fontFamily", newName);
{
const fontFamily = this.isStyle ? this.fontFamily : newName;
const fontStyle = this.isStyle ? newName : getFontStyles(newName)[0];
const fontFile = getFontFile(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFile });
}
},
onWidthChanged(newWidth: number) {
this.minWidth = newWidth;
},
updateEntries(): { menuEntries: SectionsOfMenuListEntries; activeEntry: MenuListEntry } {
let selectedIndex = -1;
const menuEntries: SectionsOfMenuListEntries = [
(this.isStyle ? getFontStyles(this.fontFamily) : fontNames()).map((name, index) => {
if (name === (this.isStyle ? this.fontStyle : this.fontFamily)) selectedIndex = index;
const result: MenuListEntry = {
label: name,
action: (): void => this.selectFont(name),
};
return result;
}),
];
const activeEntry = selectedIndex < 0 ? { label: "-" } : menuEntries.flat()[selectedIndex];
return { menuEntries, activeEntry };
},
},
watch: {
fontFamily() {
const { menuEntries, activeEntry } = this.updateEntries();
this.menuEntries = menuEntries;
this.activeEntry = activeEntry;
},
fontStyle() {
const { menuEntries, activeEntry } = this.updateEntries();
this.menuEntries = menuEntries;
this.activeEntry = activeEntry;
},
},
components: {
IconLabel,
MenuList,
LayoutRow,
},
});
</script>

View File

@ -0,0 +1,74 @@
<template>
<FieldInput
:textarea="true"
class="text-area-input"
:class="{ 'has-label': label }"
v-model:value="inputValue"
:label="label"
:spellcheck="true"
:disabled="disabled"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
></FieldInput>
</template>
<style lang="scss"></style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
editing: false,
};
},
computed: {
inputValue: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.editing = true;
},
// Called only when `value` is changed from the <textarea> element via user input and committed, either
// via the `change` event or when the <input> element is defocused (with the `blur` event binding)
onTextChanged() {
// The `inputElement.blur()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return;
this.onCancelTextChange();
// TODO: Find a less hacky way to do this
const inputElement = (this.$refs.fieldInput as typeof FieldInput).$refs.input as HTMLTextAreaElement;
this.$emit("commitText", inputElement.value);
// Required if value is not changed by the parent component upon update:value event
inputElement.value = this.value;
},
onCancelTextChange() {
this.editing = false;
const inputElement = (this.$refs.fieldInput as typeof FieldInput).$refs.input as HTMLTextAreaElement;
inputElement.blur();
},
},
components: { FieldInput },
});
</script>

View File

@ -388,6 +388,8 @@ export class IndexedDbDocumentDetails extends DocumentDetails {
id!: string;
}
export class TriggerDefaultFontLoad extends JsMessage {}
export class TriggerIndexedDbWriteDocument extends JsMessage {
document!: string;
@ -403,6 +405,10 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
document_id!: string;
}
export class TriggerFontLoad extends JsMessage {
font!: string;
}
export interface WidgetLayout {
layout_target: unknown;
layout: LayoutRow[];
@ -427,7 +433,19 @@ export function isWidgetSection(layoutRow: WidgetRow | WidgetSection): layoutRow
return Boolean((layoutRow as WidgetSection).layout);
}
export type WidgetKind = "NumberInput" | "Separator" | "IconButton" | "PopoverButton" | "OptionalInput" | "RadioInput" | "TextInput" | "TextLabel" | "IconLabel" | "ColorInput";
export type WidgetKind =
| "NumberInput"
| "Separator"
| "IconButton"
| "PopoverButton"
| "OptionalInput"
| "RadioInput"
| "TextInput"
| "TextAreaInput"
| "TextLabel"
| "IconLabel"
| "ColorInput"
| "FontInput";
export interface Widget {
kind: WidgetKind;
@ -522,9 +540,11 @@ export const messageConstructors: Record<string, MessageMaker> = {
DisplayEditableTextbox,
UpdateImageData,
DisplayRemoveEditableTextbox,
TriggerDefaultFontLoad,
TriggerFileDownload,
TriggerFileUpload,
TriggerIndexedDbRemoveDocument,
TriggerFontLoad,
TriggerIndexedDbWriteDocument,
TriggerTextCommit,
TriggerTextCopy,

View File

@ -0,0 +1,81 @@
const fontListAPI = "https://api.graphite.rs/font-list";
// Taken 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, "Normal"],
[500, "Medium"],
[600, "Semi Bold"],
[700, "Bold"],
[800, "Extra Bold"],
[900, "Black"],
[950, "Extra Black"],
]);
type fontCallbackType = (font: string, data: Uint8Array) => void;
let fontList = [] as { family: string; variants: string[]; files: Map<string, string> }[];
let loadDefaultFontCallback = undefined as fontCallbackType | undefined;
fetch(fontListAPI)
.then((response) => response.json())
.then((json) => {
const loadedFonts = json.items as { family: string; variants: string[]; files: { [name: string]: string } }[];
fontList = loadedFonts.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 };
});
loadDefaultFont();
});
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})`;
}
export function loadDefaultFont(): void {
const font = getFontFile("Merriweather", "Normal (400)");
if (font) {
fetch(font)
.then((response) => response.arrayBuffer())
.then((response) => {
if (loadDefaultFontCallback) loadDefaultFontCallback(font, new Uint8Array(response));
});
}
}
export function setLoadDefaultFontCallback(callback: fontCallbackType): void {
loadDefaultFontCallback = callback;
loadDefaultFont();
}
export function fontNames(): string[] {
return fontList.map((value) => value.family);
}
export function getFontStyles(fontFamily: string): string[] {
const font = fontList.find((value) => value.family === fontFamily);
return font ? font.variants : [];
}
export function getFontFile(fontFamily: string, fontStyle: string): string | undefined {
const font = fontList.find((value) => value.family === fontFamily);
const fontFile = font && font.files.get(fontStyle);
return fontFile && fontFile.replace("http://", "https://");
}

View File

@ -325,6 +325,14 @@ impl JsEditorHandle {
Ok(())
}
/// A font has been downloaded
pub fn on_font_load(&self, font: String, data: Vec<u8>, is_default: bool) -> Result<(), JsValue> {
let message = DocumentMessage::FontLoaded { font, data, is_default };
self.dispatch(message);
Ok(())
}
/// A text box was changed
pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> {
let message = TextMessage::UpdateBounds { new_text };

View File

@ -14,12 +14,50 @@ use kurbo::Affine;
use serde::{Deserialize, Serialize};
use std::cmp::max;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
/// A number that identifies a layer.
/// This does not technically need to be unique globally, only within a folder.
pub type LayerId = u64;
/// A cache of all loaded fonts along with a string of the name of the default font (sent from js)
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct FontCache {
data: HashMap<String, Vec<u8>>,
default_font: Option<String>,
}
impl FontCache {
/// Returns the font family name if the font is cached, otherwise returns the default font family name if that is cached
pub fn resolve_font<'a>(&'a self, font: Option<&'a String>) -> Option<&'a String> {
font.filter(|font| self.loaded_font(font))
.map_or(self.default_font.as_ref().filter(|font| self.loaded_font(font)), Some)
}
/// Try to get the bytes for a font
pub fn get<'a>(&'a self, font: Option<&String>) -> Option<&'a Vec<u8>> {
self.resolve_font(font).and_then(|font| self.data.get(font))
}
/// Check if the font is already loaded
pub fn loaded_font(&self, font: &str) -> bool {
self.data.contains_key(font)
}
/// Insert a new font into the cache
pub fn insert(&mut self, font: String, data: Vec<u8>, is_default: bool) {
if is_default {
self.default_font = Some(font.clone());
}
self.data.insert(font, data);
}
/// Checks if the font cache has a default font
pub fn has_default(&self) -> bool {
self.default_font.is_some()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Document {
/// The root layer, usually a [FolderLayer](layers::folder_layer::FolderLayer) that contains all other [Layers](layers::layer_info::Layer).
@ -28,6 +66,7 @@ pub struct Document {
/// This identifier is not a hash and is not guaranteed to be equal for equivalent documents.
#[serde(skip)]
pub state_identifier: DefaultHasher,
pub font_cache: FontCache,
}
impl Default for Document {
@ -35,6 +74,7 @@ impl Default for Document {
Self {
root: Layer::new(LayerDataType::Folder(FolderLayer::default()), DAffine2::IDENTITY.to_cols_array()),
state_identifier: DefaultHasher::new(),
font_cache: FontCache::default(),
}
}
}
@ -44,7 +84,7 @@ impl Document {
pub fn render_root(&mut self, mode: ViewMode) -> String {
let mut svg_defs = String::from("<defs>");
self.root.render(&mut vec![], mode, &mut svg_defs);
self.root.render(&mut vec![], mode, &mut svg_defs, &self.font_cache);
svg_defs.push_str("</defs>");
@ -58,7 +98,7 @@ impl Document {
/// Checks whether each layer under `path` intersects with the provided `quad` and adds all intersection layers as paths to `intersections`.
pub fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
self.layer(path).unwrap().intersects_quad(quad, path, intersections);
self.layer(path).unwrap().intersects_quad(quad, path, intersections, &self.font_cache);
}
/// Checks whether each layer under the root path intersects with the provided `quad` and returns the paths to all intersecting layers.
@ -321,13 +361,13 @@ impl Document {
pub fn viewport_bounding_box(&self, path: &[LayerId]) -> Result<Option<[DVec2; 2]>, DocumentError> {
let layer = self.layer(path)?;
let transform = self.multiply_transforms(path)?;
Ok(layer.data.bounding_box(transform))
Ok(layer.data.bounding_box(transform, &self.font_cache))
}
pub fn bounding_box_and_transform(&self, path: &[LayerId]) -> Result<Option<([DVec2; 2], DAffine2)>, DocumentError> {
let layer = self.layer(path)?;
let transform = self.multiply_transforms(&path[..path.len() - 1])?;
Ok(layer.data.bounding_box(layer.transform).map(|bounds| (bounds, transform)))
Ok(layer.data.bounding_box(layer.transform, &self.font_cache).map(|bounds| (bounds, transform)))
}
pub fn visible_layers_bounding_box(&self) -> Option<[DVec2; 2]> {
@ -491,11 +531,13 @@ impl Document {
insert_index,
transform,
text,
style,
size,
font_name,
font_style,
font_file,
} => {
let layer = Layer::new(LayerDataType::Text(TextLayer::new(text, style, size)), transform);
let layer = Layer::new(LayerDataType::Text(TextLayer::new(text, style, size, font_name, font_style, font_file, &self.font_cache)), transform);
self.set_layer(&path, layer, insert_index)?;
@ -520,7 +562,20 @@ impl Document {
Some(vec![DocumentChanged])
}
Operation::SetTextContent { path, new_text } => {
self.layer_mut(&path)?.as_text_mut()?.update_text(new_text);
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache
let mut current_folder = &mut self.root;
let (layer_path, id) = split_path(&path)?;
for id in layer_path {
current_folder = current_folder.as_folder_mut()?.layer_mut(*id).ok_or_else(|| DocumentError::LayerNotFound(layer_path.into()))?;
}
current_folder
.as_folder_mut()?
.layer_mut(id)
.ok_or_else(|| DocumentError::LayerNotFound(path.clone()))?
.as_text_mut()?
.update_text(new_text, &self.font_cache);
self.mark_as_dirty(&path)?;
Some([vec![DocumentChanged], update_thumbnails_upstream(&path)].concat())
@ -679,6 +734,30 @@ impl Document {
return Err(DocumentError::IndexOutOfBounds);
}
}
Operation::ModifyFont {
path,
font_family,
font_style,
font_file,
size,
} => {
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache
let mut current_folder = &mut self.root;
let (folder_path, id) = split_path(&path)?;
for id in folder_path {
current_folder = current_folder.as_folder_mut()?.layer_mut(*id).ok_or_else(|| DocumentError::LayerNotFound(folder_path.into()))?;
}
let layer_mut = current_folder.as_folder_mut()?.layer_mut(id).ok_or_else(|| DocumentError::LayerNotFound(folder_path.into()))?;
let text = layer_mut.as_text_mut()?;
text.font_family = font_family;
text.font_style = font_style;
text.font_file = font_file;
text.size = size;
text.regenerate_path(text.load_face(&self.font_cache));
self.mark_as_dirty(&path)?;
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat())
}
Operation::RenameLayer { layer_path: path, new_name: name } => {
self.layer_mut(&path)?.name = Some(name);
Some(vec![LayerChanged { path }])
@ -729,12 +808,20 @@ impl Document {
self.set_transform_relative_to_viewport(&path, transform)?;
self.mark_as_dirty(&path)?;
if let LayerDataType::Text(t) = &mut self.layer_mut(&path)?.data {
let bezpath = t.to_bez_path();
self.layer_mut(&path)?.data = layers::layer_info::LayerDataType::Shape(ShapeLayer::from_bez_path(bezpath, t.style.clone(), true));
// Not using Document::layer_mut is necessary because we alson need to borrow the font cache
let mut current_folder = &mut self.root;
let (folder_path, id) = split_path(&path)?;
for id in folder_path {
current_folder = current_folder.as_folder_mut()?.layer_mut(*id).ok_or_else(|| DocumentError::LayerNotFound(folder_path.into()))?;
}
let layer_mut = current_folder.as_folder_mut()?.layer_mut(id).ok_or_else(|| DocumentError::LayerNotFound(folder_path.into()))?;
if let LayerDataType::Text(t) = &mut layer_mut.data {
let bezpath = t.to_bez_path(t.load_face(&self.font_cache));
layer_mut.data = layers::layer_info::LayerDataType::Shape(ShapeLayer::from_bez_path(bezpath, t.path_style.clone(), true));
}
if let LayerDataType::Shape(shape) = &mut self.layer_mut(&path)?.data {
if let LayerDataType::Shape(shape) = &mut layer_mut.data {
shape.path = bez_path;
}
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat())
@ -795,7 +882,7 @@ impl Document {
let layer = self.layer_mut(&path)?;
match &mut layer.data {
LayerDataType::Shape(s) => s.style = style,
LayerDataType::Text(text) => text.style = style,
LayerDataType::Text(text) => text.path_style = style,
_ => return Err(DocumentError::NotAShape),
}
self.mark_as_dirty(&path)?;

View File

@ -1,5 +1,6 @@
use super::layer_info::{Layer, LayerData, LayerDataType};
use super::style::ViewMode;
use crate::document::FontCache;
use crate::intersection::Quad;
use crate::{DocumentError, LayerId};
@ -21,24 +22,24 @@ pub struct FolderLayer {
}
impl LayerData for FolderLayer {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode) {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode, font_cache: &FontCache) {
for layer in &mut self.layers {
let _ = writeln!(svg, "{}", layer.render(transforms, view_mode, svg_defs));
let _ = writeln!(svg, "{}", layer.render(transforms, view_mode, svg_defs, font_cache));
}
}
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, font_cache: &FontCache) {
for (layer, layer_id) in self.layers().iter().zip(&self.layer_ids) {
path.push(*layer_id);
layer.intersects_quad(quad, path, intersections);
layer.intersects_quad(quad, path, intersections, font_cache);
path.pop();
}
}
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
fn bounding_box(&self, transform: glam::DAffine2, font_cache: &FontCache) -> Option<[DVec2; 2]> {
self.layers
.iter()
.filter_map(|layer| layer.data.bounding_box(transform * layer.transform))
.filter_map(|layer| layer.data.bounding_box(transform * layer.transform, font_cache))
.reduce(|a, b| [a[0].min(b[0]), a[1].max(b[1])])
}
}

View File

@ -1,5 +1,6 @@
use super::layer_info::LayerData;
use super::style::ViewMode;
use crate::document::FontCache;
use crate::intersection::{intersect_quad_bez_path, Quad};
use crate::LayerId;
@ -23,7 +24,7 @@ pub struct ImageLayer {
}
impl LayerData for ImageLayer {
fn render(&mut self, svg: &mut String, _svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode) {
fn render(&mut self, svg: &mut String, _svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode, _font_cache: &FontCache) {
let transform = self.transform(transforms, view_mode);
let inverse = transform.inverse();
@ -55,7 +56,7 @@ impl LayerData for ImageLayer {
let _ = svg.write_str("</g>");
}
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
fn bounding_box(&self, transform: glam::DAffine2, _font_cache: &FontCache) -> Option<[DVec2; 2]> {
let mut path = self.bounds();
if transform.matrix2 == DMat2::ZERO {
@ -67,7 +68,7 @@ impl LayerData for ImageLayer {
Some([(x0, y0).into(), (x1, y1).into()])
}
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, _font_cache: &FontCache) {
if intersect_quad_bez_path(quad, &self.bounds(), true) {
intersections.push(path.clone());
}

View File

@ -4,6 +4,7 @@ use super::image_layer::ImageLayer;
use super::shape_layer::ShapeLayer;
use super::style::{PathStyle, ViewMode};
use super::text_layer::TextLayer;
use crate::document::FontCache;
use crate::intersection::Quad;
use crate::DocumentError;
use crate::LayerId;
@ -54,12 +55,13 @@ pub trait LayerData {
/// # use graphite_graphene::layers::shape_layer::ShapeLayer;
/// # use graphite_graphene::layers::style::{Fill, PathStyle, ViewMode};
/// # use graphite_graphene::layers::layer_info::LayerData;
/// # use std::collections::HashMap;
///
/// let mut shape = ShapeLayer::rectangle(PathStyle::new(None, Fill::None));
/// let mut svg = String::new();
///
/// // Render the shape without any transforms, in normal view mode
/// shape.render(&mut svg, &mut String::new(), &mut vec![], ViewMode::Normal);
/// shape.render(&mut svg, &mut String::new(), &mut vec![], ViewMode::Normal, &Default::default());
///
/// assert_eq!(
/// svg,
@ -68,7 +70,7 @@ pub trait LayerData {
/// </g>"
/// );
/// ```
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode);
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode, font_cache: &FontCache);
/// Determine the layers within this layer that intersect a given quad.
/// # Example
@ -78,6 +80,7 @@ pub trait LayerData {
/// # use graphite_graphene::layers::layer_info::LayerData;
/// # use graphite_graphene::intersection::Quad;
/// # use glam::f64::{DAffine2, DVec2};
/// # use std::collections::HashMap;
///
/// let mut shape = ShapeLayer::ellipse(PathStyle::new(None, Fill::None));
/// let shape_id = 42;
@ -86,11 +89,11 @@ pub trait LayerData {
/// let quad = Quad::from_box([DVec2::ZERO, DVec2::ONE]);
/// let mut intersections = vec![];
///
/// shape.intersects_quad(quad, &mut vec![shape_id], &mut intersections);
/// shape.intersects_quad(quad, &mut vec![shape_id], &mut intersections, &Default::default());
///
/// assert_eq!(intersections, vec![vec![shape_id]]);
/// ```
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>);
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, font_cache: &FontCache);
// TODO: this doctest fails because 0 != 1e-32, maybe assert difference < epsilon?
/// Calculate the bounding box for the layer's contents after applying a given transform.
@ -100,29 +103,30 @@ pub trait LayerData {
/// # use graphite_graphene::layers::style::{Fill, PathStyle};
/// # use graphite_graphene::layers::layer_info::LayerData;
/// # use glam::f64::{DAffine2, DVec2};
/// # use std::collections::HashMap;
/// let shape = ShapeLayer::ellipse(PathStyle::new(None, Fill::None));
///
/// // Calculate the bounding box without applying any transformations.
/// // (The identity transform maps every vector to itself.)
/// let transform = DAffine2::IDENTITY;
/// let bounding_box = shape.bounding_box(transform);
/// let bounding_box = shape.bounding_box(transform, &Default::default());
///
/// assert_eq!(bounding_box, Some([DVec2::ZERO, DVec2::ONE]));
/// ```
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]>;
fn bounding_box(&self, transform: glam::DAffine2, font_cache: &FontCache) -> Option<[DVec2; 2]>;
}
impl LayerData for LayerDataType {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode) {
self.inner_mut().render(svg, svg_defs, transforms, view_mode)
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<glam::DAffine2>, view_mode: ViewMode, font_cache: &FontCache) {
self.inner_mut().render(svg, svg_defs, transforms, view_mode, font_cache)
}
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
self.inner().intersects_quad(quad, path, intersections)
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, font_cache: &FontCache) {
self.inner().intersects_quad(quad, path, intersections, font_cache)
}
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
self.inner().bounding_box(transform)
fn bounding_box(&self, transform: glam::DAffine2, font_cache: &FontCache) -> Option<[DVec2; 2]> {
self.inner().bounding_box(transform, font_cache)
}
}
@ -219,7 +223,7 @@ impl Layer {
LayerIter { stack: vec![self] }
}
pub fn render(&mut self, transforms: &mut Vec<DAffine2>, view_mode: ViewMode, svg_defs: &mut String) -> &str {
pub fn render(&mut self, transforms: &mut Vec<DAffine2>, view_mode: ViewMode, svg_defs: &mut String, font_cache: &FontCache) -> &str {
if !self.visible {
return "";
}
@ -228,7 +232,7 @@ impl Layer {
transforms.push(self.transform);
self.thumbnail_cache.clear();
self.svg_defs_cache.clear();
self.data.render(&mut self.thumbnail_cache, &mut self.svg_defs_cache, transforms, view_mode);
self.data.render(&mut self.thumbnail_cache, &mut self.svg_defs_cache, transforms, view_mode, font_cache);
self.cache.clear();
let _ = writeln!(self.cache, r#"<g transform="matrix("#);
@ -250,13 +254,13 @@ impl Layer {
self.cache.as_str()
}
pub fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
pub fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, font_cache: &FontCache) {
if !self.visible {
return;
}
let transformed_quad = self.transform.inverse() * quad;
self.data.intersects_quad(transformed_quad, path, intersections)
self.data.intersects_quad(transformed_quad, path, intersections, font_cache)
}
/// Compute the bounding box of the layer after applying a transform to it.
@ -268,31 +272,32 @@ impl Layer {
/// # use graphite_graphene::layers::style::PathStyle;
/// # use glam::DVec2;
/// # use glam::f64::DAffine2;
/// # use std::collections::HashMap;
/// // Create a rectangle with the default dimensions, from `(0|0)` to `(1|1)`
/// let layer: Layer = ShapeLayer::rectangle(PathStyle::default()).into();
///
/// // Apply the Identity transform, which leaves the points unchanged
/// assert_eq!(
/// layer.aabounding_box_for_transform(DAffine2::IDENTITY),
/// layer.aabounding_box_for_transform(DAffine2::IDENTITY, &Default::default()),
/// Some([DVec2::ZERO, DVec2::ONE]),
/// );
///
/// // Apply a transform that scales every point by a factor of two
/// let transform = DAffine2::from_scale(DVec2::ONE * 2.);
/// assert_eq!(
/// layer.aabounding_box_for_transform(transform),
/// layer.aabounding_box_for_transform(transform, &Default::default()),
/// Some([DVec2::ZERO, DVec2::ONE * 2.]),
/// );
pub fn aabounding_box_for_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
self.data.bounding_box(transform)
pub fn aabounding_box_for_transform(&self, transform: DAffine2, font_cache: &FontCache) -> Option<[DVec2; 2]> {
self.data.bounding_box(transform, font_cache)
}
pub fn aabounding_box(&self) -> Option<[DVec2; 2]> {
self.aabounding_box_for_transform(self.transform)
pub fn aabounding_box(&self, font_cache: &FontCache) -> Option<[DVec2; 2]> {
self.aabounding_box_for_transform(self.transform, font_cache)
}
pub fn bounding_transform(&self) -> DAffine2 {
let scale = match self.aabounding_box_for_transform(DAffine2::IDENTITY) {
pub fn bounding_transform(&self, font_cache: &FontCache) -> DAffine2 {
let scale = match self.aabounding_box_for_transform(DAffine2::IDENTITY, font_cache) {
Some([a, b]) => {
let dimensions = b - a;
DAffine2::from_scale(dimensions)
@ -360,7 +365,7 @@ impl Layer {
pub fn style(&self) -> Result<&PathStyle, DocumentError> {
match &self.data {
LayerDataType::Shape(s) => Ok(&s.style),
LayerDataType::Text(t) => Ok(&t.style),
LayerDataType::Text(t) => Ok(&t.path_style),
_ => Err(DocumentError::NotAShape),
}
}
@ -368,7 +373,7 @@ impl Layer {
pub fn style_mut(&mut self) -> Result<&mut PathStyle, DocumentError> {
match &mut self.data {
LayerDataType::Shape(s) => Ok(&mut s.style),
LayerDataType::Text(t) => Ok(&mut t.style),
LayerDataType::Text(t) => Ok(&mut t.path_style),
_ => Err(DocumentError::NotAShape),
}
}

View File

@ -1,5 +1,6 @@
use super::layer_info::LayerData;
use super::style::{self, PathStyle, ViewMode};
use crate::document::FontCache;
use crate::intersection::{intersect_quad_bez_path, Quad};
use crate::LayerId;
@ -30,7 +31,7 @@ pub struct ShapeLayer {
}
impl LayerData for ShapeLayer {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode) {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode, _font_cache: &FontCache) {
let mut path = self.path.clone();
let kurbo::Rect { x0, y0, x1, y1 } = path.bounding_box();
@ -61,7 +62,7 @@ impl LayerData for ShapeLayer {
let _ = svg.write_str("</g>");
}
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
fn bounding_box(&self, transform: glam::DAffine2, _font_cache: &FontCache) -> Option<[DVec2; 2]> {
use kurbo::Shape;
let mut path = self.path.clone();
@ -74,7 +75,7 @@ impl LayerData for ShapeLayer {
Some([(x0, y0).into(), (x1, y1).into()])
}
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, _font_cache: &FontCache) {
if intersect_quad_bez_path(quad, &self.path, self.style.fill().is_some()) {
intersections.push(path.clone());
}

View File

@ -1,93 +0,0 @@
Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name Source.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,10 +1,12 @@
use super::layer_info::LayerData;
use super::style::{PathStyle, ViewMode};
use crate::document::FontCache;
use crate::intersection::{intersect_quad_bez_path, Quad};
use crate::LayerId;
use glam::{DAffine2, DMat2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
use rustybuzz::Face;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
@ -17,16 +19,18 @@ fn glam_to_kurbo(transform: DAffine2) -> Affine {
/// A line, or multiple lines, of text drawn in the document.
/// Like [ShapeLayers](super::shape_layer::ShapeLayer), [TextLayer] are rendered as
/// [`<path>`s](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path).
/// Currently, the only supported font is `SourceSansPro-Regular`.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct TextLayer {
/// The string of text, encompassing one or multiple lines.
pub text: String,
/// Fill color and stroke used to render the text.
pub style: PathStyle,
pub path_style: PathStyle,
/// Font size in pixels.
pub size: f64,
pub line_width: Option<f64>,
pub font_family: String,
pub font_style: String,
pub font_file: Option<String>,
#[serde(skip)]
pub editable: bool,
#[serde(skip)]
@ -34,7 +38,7 @@ pub struct TextLayer {
}
impl LayerData for TextLayer {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode) {
fn render(&mut self, svg: &mut String, svg_defs: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode, font_cache: &FontCache) {
let transform = self.transform(transforms, view_mode);
let inverse = transform.inverse();
@ -50,18 +54,26 @@ impl LayerData for TextLayer {
let _ = svg.write_str(r#")">"#);
if self.editable {
let font = font_cache.resolve_font(self.font_file.as_ref());
if let Some(url) = font {
let _ = write!(svg, r#"<style>@font-face {{font-family: local-font;src: url({});}}")</style>"#, url);
}
let _ = write!(
svg,
r#"<foreignObject transform="matrix({})"></foreignObject>"#,
r#"<foreignObject transform="matrix({})"{}></foreignObject>"#,
transform
.to_cols_array()
.iter()
.enumerate()
.map(|(i, entry)| { entry.to_string() + if i == 5 { "" } else { "," } })
.collect::<String>(),
font.map(|_| r#" style="font-family: local-font;""#).unwrap_or_default()
);
} else {
let mut path = self.to_bez_path();
let buzz_face = self.load_face(font_cache);
let mut path = self.to_bez_path(buzz_face);
let kurbo::Rect { x0, y0, x1, y1 } = path.bounding_box();
let bounds = [(x0, y0).into(), (x1, y1).into()];
@ -75,14 +87,16 @@ impl LayerData for TextLayer {
svg,
r#"<path d="{}" {} />"#,
path.to_svg(),
self.style.render(view_mode, svg_defs, transform, bounds, transformed_bounds)
self.path_style.render(view_mode, svg_defs, transform, bounds, transformed_bounds)
);
}
let _ = svg.write_str("</g>");
}
fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
let mut path = self.bounding_box(&self.text).to_path(0.1);
fn bounding_box(&self, transform: glam::DAffine2, font_cache: &FontCache) -> Option<[DVec2; 2]> {
let buzz_face = Some(self.load_face(font_cache)?);
let mut path = self.bounding_box(&self.text, buzz_face).to_path(0.1);
if transform.matrix2 == DMat2::ZERO {
return None;
@ -93,14 +107,20 @@ impl LayerData for TextLayer {
Some([(x0, y0).into(), (x1, y1).into()])
}
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>) {
if intersect_quad_bez_path(quad, &self.bounding_box(&self.text).to_path(0.), true) {
fn intersects_quad(&self, quad: Quad, path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, font_cache: &FontCache) {
let buzz_face = self.load_face(font_cache);
if intersect_quad_bez_path(quad, &self.bounding_box(&self.text, buzz_face).to_path(0.), true) {
intersections.push(path.clone());
}
}
}
impl TextLayer {
pub fn load_face<'a>(&self, font_cache: &'a FontCache) -> Option<Face<'a>> {
font_cache.get(self.font_file.as_ref()).map(|data| rustybuzz::Face::from_slice(data, 0).expect("Loading font failed"))
}
pub fn transform(&self, transforms: &[DAffine2], mode: ViewMode) -> DAffine2 {
let start = match mode {
ViewMode::Outline => 0,
@ -109,61 +129,61 @@ impl TextLayer {
transforms.iter().skip(start).cloned().reduce(|a, b| a * b).unwrap_or(DAffine2::IDENTITY)
}
pub fn new(text: String, style: PathStyle, size: f64) -> Self {
pub fn new(text: String, style: PathStyle, size: f64, font_family: String, font_style: String, font_file: Option<String>, font_cache: &FontCache) -> Self {
let mut new = Self {
text,
style,
path_style: style,
size,
line_width: None,
font_family,
font_style,
font_file,
editable: false,
cached_path: None,
};
new.regenerate_path();
new.regenerate_path(new.load_face(font_cache));
new
}
/// Converts to a [BezPath], populating the cache if necessary.
#[inline]
pub fn to_bez_path(&mut self) -> BezPath {
pub fn to_bez_path(&mut self, buzz_face: Option<Face>) -> BezPath {
if self.cached_path.is_none() {
self.regenerate_path();
self.regenerate_path(buzz_face);
}
self.cached_path.clone().unwrap()
}
/// Converts to a [BezPath], without populating the cache.
#[inline]
pub fn to_bez_path_nonmut(&self) -> BezPath {
self.cached_path.clone().unwrap_or_else(|| self.generate_path())
}
pub fn to_bez_path_nonmut(&self, font_cache: &FontCache) -> BezPath {
let buzz_face = self.load_face(font_cache);
/// Get the font face for `SourceSansPro-Regular`.
/// For now, the font is hardcoded in the wasm binary.
#[inline]
fn font_face() -> rustybuzz::Face<'static> {
rustybuzz::Face::from_slice(include_bytes!("SourceSansPro/SourceSansPro-Regular.ttf"), 0).unwrap()
self.cached_path.clone().unwrap_or_else(|| self.generate_path(buzz_face))
}
#[inline]
fn generate_path(&self) -> BezPath {
to_kurbo::to_kurbo(&self.text, Self::font_face(), self.size, self.line_width)
fn generate_path(&self, buzz_face: Option<Face>) -> BezPath {
to_kurbo::to_kurbo(&self.text, buzz_face, self.size, self.line_width)
}
#[inline]
pub fn bounding_box(&self, text: &str) -> Rect {
let far = to_kurbo::bounding_box(text, Self::font_face(), self.size, self.line_width);
pub fn bounding_box(&self, text: &str, buzz_face: Option<Face>) -> Rect {
let far = to_kurbo::bounding_box(text, buzz_face, self.size, self.line_width);
Rect::new(0., 0., far.x, far.y)
}
/// Populate the cache.
pub fn regenerate_path(&mut self) {
self.cached_path = Some(self.generate_path());
pub fn regenerate_path(&mut self, buzz_face: Option<Face>) {
self.cached_path = Some(self.generate_path(buzz_face));
}
pub fn update_text(&mut self, text: String) {
pub fn update_text(&mut self, text: String, font_cache: &FontCache) {
let buzz_face = self.load_face(font_cache);
self.text = text;
self.regenerate_path();
self.regenerate_path(buzz_face);
}
}

View File

@ -67,7 +67,13 @@ fn wrap_word(line_width: Option<f64>, glyph_buffer: &GlyphBuffer, scale: f64, x_
false
}
pub fn to_kurbo(str: &str, buzz_face: rustybuzz::Face, font_size: f64, line_width: Option<f64>) -> BezPath {
pub fn to_kurbo(str: &str, buzz_face: Option<rustybuzz::Face>, font_size: f64, line_width: Option<f64>) -> BezPath {
let buzz_face = match buzz_face {
Some(face) => face,
// Show blank layer if font has not loaded
None => return BezPath::default(),
};
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
let mut builder = Builder {
@ -106,7 +112,13 @@ pub fn to_kurbo(str: &str, buzz_face: rustybuzz::Face, font_size: f64, line_widt
builder.path
}
pub fn bounding_box(str: &str, buzz_face: rustybuzz::Face, font_size: f64, line_width: Option<f64>) -> DVec2 {
pub fn bounding_box(str: &str, buzz_face: Option<rustybuzz::Face>, font_size: f64, line_width: Option<f64>) -> DVec2 {
let buzz_face = match buzz_face {
Some(face) => face,
// Show blank layer if font has not loaded
None => return DVec2::ZERO,
};
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
let mut pos = DVec2::ZERO;

View File

@ -53,6 +53,9 @@ pub enum Operation {
text: String,
style: style::PathStyle,
size: f64,
font_name: String,
font_style: String,
font_file: Option<String>,
},
AddImage {
path: Vec<LayerId>,
@ -119,6 +122,13 @@ pub enum Operation {
DuplicateLayer {
path: Vec<LayerId>,
},
ModifyFont {
path: Vec<LayerId>,
font_family: String,
font_style: String,
font_file: Option<String>,
size: f64,
},
RenameLayer {
layer_path: Vec<LayerId>,
new_name: String,