mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Implement the Text Tool and text layer MVP (#492)
* Add text tool * Double click with the select tool to edit text * Fix (I think?) transitioning to select tool * Commit and abort text editing * Transition to a contenteditable div and autosize * Fix right click blocking * Cleanup hints * Ctrl + enter leaves text edit mode * Render indervidual bounding boxes for text * Re-format space indents * Reflect font size in the textarea * Fix change tool behaviour * Remove starting text * Populate the cache (caused doc load bug) * Remove console log * Chrome display the flashing text entry cursor * Update overlay on input * Cleanup input.ts * Fix bounding boxes * Apply review feedback * Remove manual test * Remove svg from gitignore Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
1d2768c26d
commit
121a68ad3c
33 changed files with 1152 additions and 56 deletions
60
Cargo.lock
generated
60
Cargo.lock
generated
|
@ -40,6 +40,12 @@ version = "3.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439989e6b8c38d1b6570a384ef1e49c8848128f5a97f3914baef02920842712f"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
|
@ -110,7 +116,9 @@ dependencies = [
|
|||
"glam",
|
||||
"kurbo",
|
||||
"log",
|
||||
"rustybuzz",
|
||||
"serde",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -280,6 +288,22 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44561062e583c4873162861261f16fd1d85fe927c4904d71329a4fe43dc355ef"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"smallvec",
|
||||
"ttf-parser",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.9"
|
||||
|
@ -341,6 +365,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.2"
|
||||
|
@ -390,6 +420,36 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ae2f58a822f08abdaf668897e96a5656fe72f5a9ce66422423e8849384872e6"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07547e3ee45e28326cc23faac56d44f58f16ab23e413db526debce3b0bfd2742"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58dd944fd05f2f0b5c674917aea8a4df6af84f2d8de3fe8d988b95d28fb8fb09"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.2"
|
||||
|
|
|
@ -115,6 +115,10 @@ pub enum DocumentMessage {
|
|||
SetSnapping {
|
||||
snap: bool,
|
||||
},
|
||||
SetTexboxEditability {
|
||||
path: Vec<LayerId>,
|
||||
editable: bool,
|
||||
},
|
||||
SetViewMode {
|
||||
view_mode: ViewMode,
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use super::clipboards::Clipboard;
|
||||
use super::layer_panel::{layer_panel_entry, LayerMetadata, LayerPanelEntry, RawBuffer};
|
||||
use super::layer_panel::{layer_panel_entry, LayerDataTypeDiscriminant, LayerMetadata, LayerPanelEntry, RawBuffer};
|
||||
use super::utility_types::{AlignAggregate, AlignAxis, DocumentSave, FlipAxis, VectorManipulatorSegment, VectorManipulatorShape};
|
||||
use super::vectorize_layer_metadata;
|
||||
use super::{ArtboardMessageHandler, MovementMessageHandler, OverlaysMessageHandler, TransformLayerMessageHandler};
|
||||
|
@ -146,11 +146,11 @@ impl DocumentMessageHandler {
|
|||
_ => return None,
|
||||
};
|
||||
|
||||
let shape = match &layer.ok()?.data {
|
||||
LayerDataType::Shape(shape) => Some(shape),
|
||||
LayerDataType::Folder(_) => None,
|
||||
let (path, closed) = match &layer.ok()?.data {
|
||||
LayerDataType::Shape(shape) => Some((shape.path.clone(), shape.closed)),
|
||||
LayerDataType::Text(text) => Some((text.to_bez_path_nonmut(), true)),
|
||||
_ => None,
|
||||
}?;
|
||||
let path = shape.path.clone();
|
||||
|
||||
let segments = path
|
||||
.segments()
|
||||
|
@ -170,7 +170,7 @@ impl DocumentMessageHandler {
|
|||
path,
|
||||
segments,
|
||||
transform: viewport_transform,
|
||||
closed: shape.closed,
|
||||
closed,
|
||||
})
|
||||
});
|
||||
|
||||
|
@ -204,6 +204,16 @@ impl DocumentMessageHandler {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn selected_visible_text_layers(&self) -> impl Iterator<Item = &[LayerId]> {
|
||||
self.selected_layers().filter(|path| match self.graphene_document.layer(path) {
|
||||
Ok(layer) => {
|
||||
let discriminant: LayerDataTypeDiscriminant = (&layer.data).into();
|
||||
layer.visible && discriminant == LayerDataTypeDiscriminant::Text
|
||||
}
|
||||
Err(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn visible_layers(&self) -> impl Iterator<Item = &[LayerId]> {
|
||||
self.all_layers().filter(|path| match self.graphene_document.layer(path) {
|
||||
Ok(layer) => layer.visible,
|
||||
|
@ -216,17 +226,14 @@ impl DocumentMessageHandler {
|
|||
for (id, layer) in folder.layer_ids.iter().zip(folder.layers()).rev() {
|
||||
data.push(*id);
|
||||
space += 1;
|
||||
match layer.data {
|
||||
LayerDataType::Shape(_) => (),
|
||||
LayerDataType::Folder(ref folder) => {
|
||||
path.push(*id);
|
||||
if self.layer_metadata(path).expanded {
|
||||
structure.push(space);
|
||||
self.serialize_structure(folder, structure, data, path);
|
||||
space = 0;
|
||||
}
|
||||
path.pop();
|
||||
if let LayerDataType::Folder(ref folder) = layer.data {
|
||||
path.push(*id);
|
||||
if self.layer_metadata(path).expanded {
|
||||
structure.push(space);
|
||||
self.serialize_structure(folder, structure, data, path);
|
||||
space = 0;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
structure.push(space | 1 << 63);
|
||||
|
@ -962,6 +969,22 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
|
|||
SetSnapping { snap } => {
|
||||
self.snapping_enabled = snap;
|
||||
}
|
||||
SetTexboxEditability { path, editable } => {
|
||||
let text = self.graphene_document.layer(&path).unwrap().as_text().unwrap();
|
||||
responses.push_back(DocumentOperation::SetTextEditability { path, editable }.into());
|
||||
if editable {
|
||||
responses.push_back(
|
||||
FrontendMessage::DisplayEditableTextbox {
|
||||
text: text.text.clone(),
|
||||
line_width: text.line_width,
|
||||
font_size: text.size,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
responses.push_back(FrontendMessage::DisplayRemoveEditableTextbox.into());
|
||||
}
|
||||
}
|
||||
SetViewMode { view_mode } => {
|
||||
self.view_mode = view_mode;
|
||||
responses.push_front(DocumentMessage::DirtyRenderDocument.into());
|
||||
|
|
|
@ -97,6 +97,7 @@ pub struct LayerPanelEntry {
|
|||
pub enum LayerDataTypeDiscriminant {
|
||||
Folder,
|
||||
Shape,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl fmt::Display for LayerDataTypeDiscriminant {
|
||||
|
@ -104,6 +105,7 @@ impl fmt::Display for LayerDataTypeDiscriminant {
|
|||
let name = match self {
|
||||
LayerDataTypeDiscriminant::Folder => "Folder",
|
||||
LayerDataTypeDiscriminant::Shape => "Shape",
|
||||
LayerDataTypeDiscriminant::Text => "Text",
|
||||
};
|
||||
|
||||
formatter.write_str(name)
|
||||
|
@ -117,6 +119,7 @@ impl From<&LayerDataType> for LayerDataTypeDiscriminant {
|
|||
match data {
|
||||
Folder(_) => LayerDataTypeDiscriminant::Folder,
|
||||
Shape(_) => LayerDataTypeDiscriminant::Shape,
|
||||
Text(_) => LayerDataTypeDiscriminant::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,12 +18,15 @@ pub enum FrontendMessage {
|
|||
DisplayDialogError { title: String, description: String },
|
||||
DisplayDialogPanic { panic_info: String, title: String, description: String },
|
||||
DisplayDocumentLayerTreeStructure { data_buffer: RawBuffer },
|
||||
DisplayEditableTextbox { text: String, line_width: Option<f64>, font_size: f64 },
|
||||
DisplayRemoveEditableTextbox,
|
||||
|
||||
// Trigger prefix: cause a browser API to do something
|
||||
TriggerFileDownload { document: String, name: String },
|
||||
TriggerFileUpload,
|
||||
TriggerIndexedDbRemoveDocument { document_id: u64 },
|
||||
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
|
||||
TriggerTextCommit,
|
||||
|
||||
// Update prefix: give the frontend a new value or state for it to use
|
||||
UpdateActiveDocument { document_id: u64 },
|
||||
|
|
|
@ -14,4 +14,5 @@ pub enum MouseCursorIcon {
|
|||
ZoomOut,
|
||||
Grabbing,
|
||||
Crosshair,
|
||||
Text,
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ pub struct Mapping {
|
|||
pub key_down: [KeyMappingEntries; NUMBER_OF_KEYS],
|
||||
pub pointer_move: KeyMappingEntries,
|
||||
pub mouse_scroll: KeyMappingEntries,
|
||||
pub double_click: KeyMappingEntries,
|
||||
}
|
||||
|
||||
impl Default for Mapping {
|
||||
|
@ -44,6 +45,7 @@ impl Default for Mapping {
|
|||
entry! {action=SelectMessage::MouseMove { snap_angle: KeyShift }, message=InputMapperMessage::PointerMove},
|
||||
entry! {action=SelectMessage::DragStart { add_to_selection: KeyShift }, key_down=Lmb},
|
||||
entry! {action=SelectMessage::DragStop, key_up=Lmb},
|
||||
entry! {action=SelectMessage::EditText, message=InputMapperMessage::DoubleClick},
|
||||
entry! {action=SelectMessage::Abort, key_down=Rmb},
|
||||
entry! {action=SelectMessage::Abort, key_down=KeyEscape},
|
||||
// Navigate
|
||||
|
@ -59,6 +61,10 @@ impl Default for Mapping {
|
|||
// Eyedropper
|
||||
entry! {action=EyedropperMessage::LeftMouseDown, key_down=Lmb},
|
||||
entry! {action=EyedropperMessage::RightMouseDown, key_down=Rmb},
|
||||
// Text
|
||||
entry! {action=TextMessage::Interact, key_up=Lmb},
|
||||
entry! {action=TextMessage::Abort, key_down=KeyEscape},
|
||||
entry! {action=TextMessage::CommitText, key_down=KeyEnter, modifiers=[KeyControl]},
|
||||
// Rectangle
|
||||
entry! {action=RectangleMessage::DragStart, key_down=Lmb},
|
||||
entry! {action=RectangleMessage::DragStop, key_up=Lmb},
|
||||
|
@ -105,6 +111,7 @@ impl Default for Mapping {
|
|||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Select }, key_down=KeyV},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Navigate }, key_down=KeyZ},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Eyedropper }, key_down=KeyI},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Text }, key_down=KeyT},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Fill }, key_down=KeyF},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Path }, key_down=KeyA},
|
||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Pen }, key_down=KeyP},
|
||||
|
@ -203,7 +210,7 @@ impl Default for Mapping {
|
|||
entry! {action=GlobalMessage::LogDebug, key_down=Key2},
|
||||
entry! {action=GlobalMessage::LogTrace, key_down=Key3},
|
||||
];
|
||||
let (mut key_up, mut key_down, mut pointer_move, mut mouse_scroll) = mappings;
|
||||
let (mut key_up, mut key_down, mut pointer_move, mut mouse_scroll, mut double_click) = mappings;
|
||||
|
||||
// TODO: Hardcode these 10 lines into 10 lines of declarations, or make this use a macro to do all 10 in one line
|
||||
const NUMBER_KEYS: [Key; 10] = [Key0, Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9];
|
||||
|
@ -226,12 +233,14 @@ impl Default for Mapping {
|
|||
}
|
||||
sort(&mut pointer_move);
|
||||
sort(&mut mouse_scroll);
|
||||
sort(&mut double_click);
|
||||
|
||||
Self {
|
||||
key_up,
|
||||
key_down,
|
||||
pointer_move,
|
||||
mouse_scroll,
|
||||
double_click,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -243,6 +252,7 @@ impl Mapping {
|
|||
let list = match message {
|
||||
KeyDown(key) => &self.key_down[key as usize],
|
||||
KeyUp(key) => &self.key_up[key as usize],
|
||||
DoubleClick => &self.double_click,
|
||||
MouseScroll => &self.mouse_scroll,
|
||||
PointerMove => &self.pointer_move,
|
||||
};
|
||||
|
@ -331,6 +341,7 @@ mod input_mapper_macros {
|
|||
let mut key_down = KeyMappingEntries::key_array();
|
||||
let mut pointer_move: KeyMappingEntries = Default::default();
|
||||
let mut mouse_scroll: KeyMappingEntries = Default::default();
|
||||
let mut double_click: KeyMappingEntries = Default::default();
|
||||
$(
|
||||
for entry in $entry {
|
||||
let arr = match entry.trigger {
|
||||
|
@ -338,11 +349,12 @@ mod input_mapper_macros {
|
|||
InputMapperMessage::KeyUp(key) => &mut key_up[key as usize],
|
||||
InputMapperMessage::MouseScroll => &mut mouse_scroll,
|
||||
InputMapperMessage::PointerMove => &mut pointer_move,
|
||||
InputMapperMessage::DoubleClick => &mut double_click,
|
||||
};
|
||||
arr.push(entry.clone());
|
||||
}
|
||||
)*
|
||||
(key_up, key_down, pointer_move, mouse_scroll)
|
||||
(key_up, key_down, pointer_move, mouse_scroll, double_click)
|
||||
}};
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ pub enum InputMapperMessage {
|
|||
KeyUp(Key),
|
||||
|
||||
// Messages
|
||||
DoubleClick,
|
||||
MouseScroll,
|
||||
PointerMove,
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum InputPreprocessorMessage {
|
||||
BoundsOfViewports { bounds_of_viewports: Vec<ViewportBounds> },
|
||||
DoubleClick { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
|
||||
KeyDown { key: Key, modifier_keys: ModifierKeys },
|
||||
KeyUp { key: Key, modifier_keys: ModifierKeys },
|
||||
MouseDown { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
|
||||
|
|
|
@ -49,6 +49,14 @@ impl MessageHandler<InputPreprocessorMessage, ()> for InputPreprocessorMessageHa
|
|||
);
|
||||
}
|
||||
}
|
||||
InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys } => {
|
||||
self.handle_modifier_keys(modifier_keys, responses);
|
||||
|
||||
let mouse_state = editor_mouse_state.to_mouse_state(&self.viewport_bounds);
|
||||
self.mouse.position = mouse_state.position;
|
||||
|
||||
responses.push_back(InputMapperMessage::DoubleClick.into());
|
||||
}
|
||||
InputPreprocessorMessage::KeyDown { key, modifier_keys } => {
|
||||
self.handle_modifier_keys(modifier_keys, responses);
|
||||
self.keyboard.set(key as usize);
|
||||
|
|
|
@ -81,6 +81,7 @@ pub mod message_prelude {
|
|||
pub use crate::viewport_tools::tools::rectangle::{RectangleMessage, RectangleMessageDiscriminant};
|
||||
pub use crate::viewport_tools::tools::select::{SelectMessage, SelectMessageDiscriminant};
|
||||
pub use crate::viewport_tools::tools::shape::{ShapeMessage, ShapeMessageDiscriminant};
|
||||
pub use crate::viewport_tools::tools::text::{TextMessage, TextMessageDiscriminant};
|
||||
pub use graphite_proc_macros::*;
|
||||
|
||||
pub use std::collections::VecDeque;
|
||||
|
|
|
@ -76,7 +76,7 @@ impl Default for ToolFsmState {
|
|||
Crop => crop::Crop,
|
||||
Navigate => navigate::Navigate,
|
||||
Eyedropper => eyedropper::Eyedropper,
|
||||
// Text => text::Text,
|
||||
Text => text::Text,
|
||||
Fill => fill::Fill,
|
||||
// Gradient => gradient::Gradient,
|
||||
// Brush => brush::Brush,
|
||||
|
@ -208,7 +208,7 @@ impl ToolType {
|
|||
ToolType::Crop => ToolOptions::Crop {},
|
||||
ToolType::Navigate => ToolOptions::Navigate {},
|
||||
ToolType::Eyedropper => ToolOptions::Eyedropper {},
|
||||
ToolType::Text => ToolOptions::Text {},
|
||||
ToolType::Text => ToolOptions::Text { font_size: 14 },
|
||||
ToolType::Fill => ToolOptions::Fill {},
|
||||
ToolType::Gradient => ToolOptions::Gradient {},
|
||||
ToolType::Brush => ToolOptions::Brush {},
|
||||
|
@ -241,10 +241,10 @@ pub fn standard_tool_message(tool: ToolType, message_type: StandardToolMessageTy
|
|||
match message_type {
|
||||
StandardToolMessageType::DocumentIsDirty => match tool {
|
||||
ToolType::Select => Some(SelectMessage::DocumentIsDirty.into()),
|
||||
ToolType::Crop => None, // Some(CropMessage::DocumentIsDirty.into()),
|
||||
ToolType::Navigate => None, // Some(NavigateMessage::DocumentIsDirty.into()),
|
||||
ToolType::Eyedropper => None, // Some(EyedropperMessage::DocumentIsDirty.into()),
|
||||
ToolType::Text => None, // Some(TextMessage::DocumentIsDirty.into()),
|
||||
ToolType::Crop => None, // Some(CropMessage::DocumentIsDirty.into()),
|
||||
ToolType::Navigate => None, // Some(NavigateMessage::DocumentIsDirty.into()),
|
||||
ToolType::Eyedropper => None, // Some(EyedropperMessage::DocumentIsDirty.into()),
|
||||
ToolType::Text => Some(TextMessage::DocumentIsDirty.into()),
|
||||
ToolType::Fill => None, // Some(FillMessage::DocumentIsDirty.into()),
|
||||
ToolType::Gradient => None, // Some(GradientMessage::DocumentIsDirty.into()),
|
||||
ToolType::Brush => None, // Some(BrushMessage::DocumentIsDirty.into()),
|
||||
|
@ -267,7 +267,7 @@ pub fn standard_tool_message(tool: ToolType, message_type: StandardToolMessageTy
|
|||
// ToolType::Crop => Some(CropMessage::Abort.into()),
|
||||
ToolType::Navigate => Some(NavigateMessage::Abort.into()),
|
||||
ToolType::Eyedropper => Some(EyedropperMessage::Abort.into()),
|
||||
// ToolType::Text => Some(TextMessage::Abort.into()),
|
||||
ToolType::Text => Some(TextMessage::Abort.into()),
|
||||
ToolType::Fill => Some(FillMessage::Abort.into()),
|
||||
// ToolType::Gradient => Some(GradientMessage::Abort.into()),
|
||||
// ToolType::Brush => Some(BrushMessage::Abort.into()),
|
||||
|
@ -297,7 +297,7 @@ pub fn message_to_tool_type(message: &ToolMessage) -> ToolType {
|
|||
Crop(_) => ToolType::Crop,
|
||||
Navigate(_) => ToolType::Navigate,
|
||||
Eyedropper(_) => ToolType::Eyedropper,
|
||||
// Text(_) => ToolType::Text,
|
||||
Text(_) => ToolType::Text,
|
||||
Fill(_) => ToolType::Fill,
|
||||
// Gradient(_) => ToolType::Gradient,
|
||||
// Brush(_) => ToolType::Brush,
|
||||
|
|
|
@ -28,6 +28,9 @@ pub enum ToolMessage {
|
|||
// Text(TextMessage),
|
||||
#[remain::unsorted]
|
||||
#[child]
|
||||
Text(TextMessage),
|
||||
#[remain::unsorted]
|
||||
#[child]
|
||||
Fill(FillMessage),
|
||||
// #[remain::unsorted]
|
||||
// #[child]
|
||||
|
|
|
@ -6,7 +6,7 @@ pub enum ToolOptions {
|
|||
Crop {},
|
||||
Navigate {},
|
||||
Eyedropper {},
|
||||
Text {},
|
||||
Text { font_size: u32 },
|
||||
Fill {},
|
||||
Gradient {},
|
||||
Brush {},
|
||||
|
|
|
@ -11,3 +11,4 @@ pub mod rectangle;
|
|||
pub mod select;
|
||||
pub mod shape;
|
||||
pub mod shared;
|
||||
pub mod text;
|
||||
|
|
|
@ -8,7 +8,7 @@ use crate::input::InputPreprocessorMessageHandler;
|
|||
use crate::message_prelude::*;
|
||||
use crate::misc::{HintData, HintGroup, HintInfo, KeysGroup};
|
||||
use crate::viewport_tools::snapping::SnapHandler;
|
||||
use crate::viewport_tools::tool::{DocumentToolData, Fsm, ToolActionHandlerData};
|
||||
use crate::viewport_tools::tool::{DocumentToolData, Fsm, ToolActionHandlerData, ToolType};
|
||||
|
||||
use graphene::document::Document;
|
||||
use graphene::intersection::Quad;
|
||||
|
@ -43,6 +43,7 @@ pub enum SelectMessage {
|
|||
add_to_selection: Key,
|
||||
},
|
||||
DragStop,
|
||||
EditText,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
MouseMove {
|
||||
|
@ -75,9 +76,9 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for Select {
|
|||
use SelectToolFsmState::*;
|
||||
|
||||
match self.fsm_state {
|
||||
Ready => actions!(SelectMessageDiscriminant; DragStart),
|
||||
Dragging => actions!(SelectMessageDiscriminant; DragStop, MouseMove),
|
||||
DrawingBox => actions!(SelectMessageDiscriminant; DragStop, MouseMove, Abort),
|
||||
Ready => actions!(SelectMessageDiscriminant; DragStart, EditText),
|
||||
Dragging => actions!(SelectMessageDiscriminant; DragStop, MouseMove, EditText),
|
||||
DrawingBox => actions!(SelectMessageDiscriminant; DragStop, MouseMove, Abort, EditText),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -173,6 +174,24 @@ impl Fsm for SelectToolFsmState {
|
|||
buffer.into_iter().rev().for_each(|message| responses.push_front(message));
|
||||
self
|
||||
}
|
||||
(_, EditText) => {
|
||||
let mouse_pos = input.mouse.position;
|
||||
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
|
||||
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
|
||||
|
||||
if document
|
||||
.graphene_document
|
||||
.intersects_quad_root(quad)
|
||||
.last()
|
||||
.map(|l| document.graphene_document.layer(l).map(|l| l.as_text().is_ok()).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
responses.push_front(ToolMessage::ActivateTool { tool_type: ToolType::Text }.into());
|
||||
responses.push_back(TextMessage::Interact.into());
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
(Ready, DragStart { add_to_selection }) => {
|
||||
data.drag_start = input.mouse.position;
|
||||
data.drag_current = input.mouse.position;
|
||||
|
|
355
editor/src/viewport_tools/tools/text.rs
Normal file
355
editor/src/viewport_tools/tools/text.rs
Normal file
|
@ -0,0 +1,355 @@
|
|||
use crate::consts::{COLOR_ACCENT, SELECTION_TOLERANCE};
|
||||
use crate::document::DocumentMessageHandler;
|
||||
use crate::frontend::utility_types::MouseCursorIcon;
|
||||
use crate::input::keyboard::{Key, MouseMotion};
|
||||
use crate::input::InputPreprocessorMessageHandler;
|
||||
use crate::message_prelude::*;
|
||||
use crate::misc::{HintData, HintGroup, HintInfo, KeysGroup};
|
||||
use crate::viewport_tools::tool::{DocumentToolData, Fsm, ToolActionHandlerData, ToolType};
|
||||
use crate::viewport_tools::tool_options::ToolOptions;
|
||||
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphene::intersection::Quad;
|
||||
use graphene::layers::style::{self, Fill, Stroke};
|
||||
use graphene::Operation;
|
||||
use kurbo::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Text {
|
||||
fsm_state: TextToolFsmState,
|
||||
data: TextToolData,
|
||||
}
|
||||
|
||||
#[remain::sorted]
|
||||
#[impl_message(Message, ToolMessage, Text)]
|
||||
#[derive(PartialEq, Clone, Debug, Hash, Serialize, Deserialize)]
|
||||
pub enum TextMessage {
|
||||
// Standard messages
|
||||
#[remain::unsorted]
|
||||
Abort,
|
||||
|
||||
#[remain::unsorted]
|
||||
DocumentIsDirty,
|
||||
|
||||
// Tool-specific messages
|
||||
CommitText,
|
||||
Interact,
|
||||
TextChange {
|
||||
new_text: String,
|
||||
},
|
||||
UpdateBounds {
|
||||
new_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for Text {
|
||||
fn process_action(&mut self, action: ToolMessage, data: ToolActionHandlerData<'a>, responses: &mut VecDeque<Message>) {
|
||||
if action == ToolMessage::UpdateHints {
|
||||
self.fsm_state.update_hints(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
if action == ToolMessage::UpdateCursor {
|
||||
self.fsm_state.update_cursor(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
let new_state = self.fsm_state.transition(action, data.0, data.1, &mut self.data, data.2, responses);
|
||||
|
||||
if self.fsm_state != new_state {
|
||||
self.fsm_state = new_state;
|
||||
self.fsm_state.update_hints(responses);
|
||||
self.fsm_state.update_cursor(responses);
|
||||
}
|
||||
}
|
||||
|
||||
fn actions(&self) -> ActionList {
|
||||
use TextToolFsmState::*;
|
||||
|
||||
match self.fsm_state {
|
||||
Ready => actions!(TextMessageDiscriminant; Interact),
|
||||
Editing => actions!(TextMessageDiscriminant; Interact, Abort, CommitText),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TextToolFsmState {
|
||||
Ready,
|
||||
Editing,
|
||||
}
|
||||
|
||||
impl Default for TextToolFsmState {
|
||||
fn default() -> Self {
|
||||
TextToolFsmState::Ready
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TextToolData {
|
||||
path: Vec<LayerId>,
|
||||
overlays: Vec<Vec<LayerId>>,
|
||||
}
|
||||
|
||||
fn transform_from_box(pos1: DVec2, pos2: DVec2) -> [f64; 6] {
|
||||
DAffine2::from_scale_angle_translation((pos2 - pos1).round(), 0., pos1.round() - DVec2::splat(0.5)).to_cols_array()
|
||||
}
|
||||
|
||||
fn resize_overlays(overlays: &mut Vec<Vec<LayerId>>, responses: &mut VecDeque<Message>, newlen: usize) {
|
||||
while overlays.len() > newlen {
|
||||
let operation = Operation::DeleteLayer { path: overlays.pop().unwrap() };
|
||||
responses.push_back(DocumentMessage::Overlays(operation.into()).into());
|
||||
}
|
||||
while overlays.len() < newlen {
|
||||
let path = vec![generate_uuid()];
|
||||
overlays.push(path.clone());
|
||||
|
||||
let operation = Operation::AddOverlayRect {
|
||||
path,
|
||||
transform: DAffine2::ZERO.to_cols_array(),
|
||||
style: style::PathStyle::new(Some(Stroke::new(COLOR_ACCENT, 1.0)), Some(Fill::none())),
|
||||
};
|
||||
responses.push_back(DocumentMessage::Overlays(operation.into()).into());
|
||||
}
|
||||
}
|
||||
|
||||
fn update_overlays(document: &DocumentMessageHandler, data: &mut TextToolData, responses: &mut VecDeque<Message>) {
|
||||
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()
|
||||
.current_bounding_box_with_transform(document.graphene_document.multiply_transforms(layer_path).unwrap())
|
||||
.unwrap();
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
impl Fsm for TextToolFsmState {
|
||||
type ToolData = TextToolData;
|
||||
|
||||
fn transition(
|
||||
self,
|
||||
event: ToolMessage,
|
||||
document: &DocumentMessageHandler,
|
||||
tool_data: &DocumentToolData,
|
||||
data: &mut Self::ToolData,
|
||||
input: &InputPreprocessorMessageHandler,
|
||||
responses: &mut VecDeque<Message>,
|
||||
) -> Self {
|
||||
use TextMessage::*;
|
||||
use TextToolFsmState::*;
|
||||
|
||||
if let ToolMessage::Text(event) = event {
|
||||
match (self, event) {
|
||||
(state, DocumentIsDirty) => {
|
||||
update_overlays(document, data, responses);
|
||||
|
||||
state
|
||||
}
|
||||
(state, Interact) => {
|
||||
let mouse_pos = input.mouse.position;
|
||||
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
|
||||
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
|
||||
|
||||
let new_state = if let Some(l) = document
|
||||
.graphene_document
|
||||
.intersects_quad_root(quad)
|
||||
.last()
|
||||
.filter(|l| document.graphene_document.layer(l).map(|l| l.as_text().is_ok()).unwrap_or(false))
|
||||
// Editing existing text
|
||||
{
|
||||
if state == TextToolFsmState::Editing {
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: false,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
data.path = l.clone();
|
||||
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: true,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
responses.push_back(
|
||||
DocumentMessage::SetSelectedLayers {
|
||||
replacement_selected_layers: vec![data.path.clone()],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
Editing
|
||||
}
|
||||
// Creating new text
|
||||
else if state == TextToolFsmState::Ready {
|
||||
let transform = DAffine2::from_translation(input.mouse.position).to_cols_array();
|
||||
let font_size = match tool_data.tool_options.get(&ToolType::Text) {
|
||||
Some(&ToolOptions::Text { font_size }) => font_size,
|
||||
_ => 14,
|
||||
};
|
||||
data.path = vec![generate_uuid()];
|
||||
|
||||
responses.push_back(
|
||||
Operation::AddText {
|
||||
path: data.path.clone(),
|
||||
transform: DAffine2::ZERO.to_cols_array(),
|
||||
insert_index: -1,
|
||||
text: r#""#.to_string(),
|
||||
style: style::PathStyle::new(Some(style::Stroke::new(tool_data.primary_color, 0.)), None),
|
||||
size: font_size as f64,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
responses.push_back(Operation::SetLayerTransformInViewport { path: data.path.clone(), transform }.into());
|
||||
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: true,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
responses.push_back(
|
||||
DocumentMessage::SetSelectedLayers {
|
||||
replacement_selected_layers: vec![data.path.clone()],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
Editing
|
||||
} else {
|
||||
// Removing old text as editable
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: false,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
resize_overlays(&mut data.overlays, responses, 0);
|
||||
|
||||
Ready
|
||||
};
|
||||
|
||||
new_state
|
||||
}
|
||||
(state, Abort) => {
|
||||
if state == TextToolFsmState::Editing {
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: false,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
resize_overlays(&mut data.overlays, responses, 0);
|
||||
|
||||
Ready
|
||||
}
|
||||
(Editing, CommitText) => {
|
||||
responses.push_back(FrontendMessage::TriggerTextCommit.into());
|
||||
|
||||
Editing
|
||||
}
|
||||
(Editing, TextChange { new_text }) => {
|
||||
responses.push_back(Operation::SetTextContent { path: data.path.clone(), new_text }.into());
|
||||
|
||||
responses.push_back(
|
||||
DocumentMessage::SetTexboxEditability {
|
||||
path: data.path.clone(),
|
||||
editable: false,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
resize_overlays(&mut data.overlays, responses, 0);
|
||||
|
||||
Ready
|
||||
}
|
||||
(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);
|
||||
|
||||
fn glam_to_kurbo(transform: DAffine2) -> kurbo::Affine {
|
||||
kurbo::Affine::new(transform.to_cols_array())
|
||||
}
|
||||
|
||||
path.apply_affine(glam_to_kurbo(document.graphene_document.multiply_transforms(&data.path).unwrap()));
|
||||
|
||||
let kurbo::Rect { x0, y0, x1, y1 } = path.bounding_box();
|
||||
|
||||
let operation = Operation::SetLayerTransformInViewport {
|
||||
path: data.overlays[0].clone(),
|
||||
transform: transform_from_box(DVec2::new(x0, y0), DVec2::new(x1, y1)),
|
||||
};
|
||||
responses.push_back(DocumentMessage::Overlays(operation.into()).into());
|
||||
|
||||
Editing
|
||||
}
|
||||
_ => self,
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn update_hints(&self, responses: &mut VecDeque<Message>) {
|
||||
let hint_data = match self {
|
||||
TextToolFsmState::Ready => HintData(vec![HintGroup(vec![
|
||||
HintInfo {
|
||||
key_groups: vec![],
|
||||
mouse: Some(MouseMotion::Lmb),
|
||||
label: String::from("Add Text"),
|
||||
plus: false,
|
||||
},
|
||||
HintInfo {
|
||||
key_groups: vec![],
|
||||
mouse: Some(MouseMotion::Lmb),
|
||||
label: String::from("Edit Text"),
|
||||
plus: false,
|
||||
},
|
||||
])]),
|
||||
TextToolFsmState::Editing => HintData(vec![HintGroup(vec![
|
||||
HintInfo {
|
||||
key_groups: vec![KeysGroup(vec![Key::KeyControl, Key::KeyEnter])],
|
||||
mouse: None,
|
||||
label: String::from("Commit Edit"),
|
||||
plus: false,
|
||||
},
|
||||
HintInfo {
|
||||
key_groups: vec![KeysGroup(vec![Key::KeyEscape])],
|
||||
mouse: None,
|
||||
label: String::from("Discard Edit"),
|
||||
plus: false,
|
||||
},
|
||||
])]),
|
||||
};
|
||||
|
||||
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
|
||||
}
|
||||
|
||||
fn update_cursor(&self, responses: &mut VecDeque<Message>) {
|
||||
responses.push_back(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Text }.into());
|
||||
}
|
||||
}
|
|
@ -266,6 +266,7 @@ export default defineComponent({
|
|||
dialog: this.dialog,
|
||||
documents: this.documents,
|
||||
fullscreen: this.fullscreen,
|
||||
inputManager: this.inputManager,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
<Separator :type="'Section'" :direction="'Vertical'" />
|
||||
|
||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => (dialog.comingSoon(153), false) && selectTool('Text')" />
|
||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => selectTool('Text')" />
|
||||
<ShelfItemInput icon="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
||||
<ShelfItemInput
|
||||
icon="ParametricGradientTool"
|
||||
|
@ -246,6 +246,32 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
foreignObject {
|
||||
overflow: visible;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
div {
|
||||
color: black;
|
||||
background: none;
|
||||
cursor: text;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
|
||||
padding-left: 1px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
div:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -253,7 +279,7 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
|
||||
import {
|
||||
UpdateDocumentArtwork,
|
||||
|
@ -266,6 +292,9 @@ import {
|
|||
ToolName,
|
||||
UpdateDocumentArtboards,
|
||||
UpdateMouseCursor,
|
||||
TriggerTextCommit,
|
||||
DisplayRemoveEditableTextbox,
|
||||
DisplayEditableTextbox,
|
||||
} from "@/dispatcher/js-messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
|
@ -347,13 +376,46 @@ export default defineComponent({
|
|||
this.editor.instance.reset_colors();
|
||||
},
|
||||
canvasPointerDown(e: PointerEvent) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
|
||||
if (!onEditbox) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
||||
this.artworkSvg = UpdateDocumentArtwork.svg;
|
||||
|
||||
nextTick((): void => {
|
||||
if (this.textInput) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
const foreignObject = canvas.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
|
||||
if (foreignObject.children.length > 0) return;
|
||||
|
||||
const addedInput = foreignObject.appendChild(this.textInput);
|
||||
|
||||
nextTick((): void => {
|
||||
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(addedInput);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
addedInput.focus();
|
||||
addedInput.click();
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("modifyinputfield", {
|
||||
detail: addedInput,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
|
||||
|
@ -393,6 +455,31 @@ export default defineComponent({
|
|||
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
||||
this.canvasCursor = updateMouseCursor.cursor;
|
||||
});
|
||||
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
|
||||
if (this.textInput) this.editor.instance.on_change_text(this.textInput.textContent || "");
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
|
||||
this.textInput = document.createElement("DIV") as HTMLDivElement;
|
||||
this.textInput.id = "editable-textbox";
|
||||
this.textInput.textContent = displayEditableTextbox.text;
|
||||
this.textInput.contentEditable = "true";
|
||||
this.textInput.style.width = displayEditableTextbox.line_width ? `${displayEditableTextbox.line_width}px` : "max-content";
|
||||
this.textInput.style.height = "auto";
|
||||
this.textInput.style.fontSize = `${displayEditableTextbox.font_size}px`;
|
||||
this.textInput.oninput = (): void => {
|
||||
if (this.textInput) this.editor.instance.update_bounds(this.textInput.textContent || "");
|
||||
};
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
|
||||
this.textInput = undefined;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("modifyinputfield", {
|
||||
detail: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", this.viewportResize);
|
||||
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
||||
|
@ -435,6 +522,7 @@ export default defineComponent({
|
|||
rulerOrigin: { x: 0, y: 0 },
|
||||
rulerSpacing: 100,
|
||||
rulerInterval: 100,
|
||||
textInput: undefined as undefined | HTMLDivElement,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -154,7 +154,7 @@ export default defineComponent({
|
|||
Crop: [],
|
||||
Navigate: [],
|
||||
Eyedropper: [],
|
||||
Text: [],
|
||||
Text: [{ kind: "NumberInput", optionPath: ["font_size"], props: { min: 1, isInteger: true, unit: " px", label: "Font size" } }],
|
||||
Fill: [],
|
||||
Gradient: [],
|
||||
Brush: [],
|
||||
|
|
|
@ -201,7 +201,7 @@ export class UpdateDocumentRulers extends JsMessage {
|
|||
readonly interval!: number;
|
||||
}
|
||||
|
||||
export type MouseCursorIcon = "default" | "zoom-in" | "zoom-out" | "grabbing" | "crosshair";
|
||||
export type MouseCursorIcon = "default" | "zoom-in" | "zoom-out" | "grabbing" | "crosshair" | "text";
|
||||
|
||||
const ToCssCursorProperty = Transform(({ value }) => {
|
||||
const cssNames: Record<string, MouseCursorIcon> = {
|
||||
|
@ -209,6 +209,7 @@ const ToCssCursorProperty = Transform(({ value }) => {
|
|||
ZoomOut: "zoom-out",
|
||||
Grabbing: "grabbing",
|
||||
Crosshair: "crosshair",
|
||||
Text: "text",
|
||||
};
|
||||
|
||||
return cssNames[value] || "default";
|
||||
|
@ -294,6 +295,16 @@ export function newDisplayDocumentLayerTreeStructure(input: { data_buffer: DataB
|
|||
return currentFolder;
|
||||
}
|
||||
|
||||
export class DisplayEditableTextbox extends JsMessage {
|
||||
readonly text!: string;
|
||||
|
||||
readonly line_width!: undefined | number;
|
||||
|
||||
readonly font_size!: number;
|
||||
}
|
||||
|
||||
export class DisplayRemoveEditableTextbox extends JsMessage {}
|
||||
|
||||
export class UpdateDocumentLayer extends JsMessage {
|
||||
@Type(() => LayerPanelEntry)
|
||||
readonly data!: LayerPanelEntry;
|
||||
|
@ -375,6 +386,8 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
|||
document_id!: string;
|
||||
}
|
||||
|
||||
export class TriggerTextCommit extends JsMessage {}
|
||||
|
||||
// Any is used since the type of the object should be known from the rust side
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage;
|
||||
|
@ -388,6 +401,8 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
|||
TriggerFileDownload,
|
||||
TriggerFileUpload,
|
||||
DisplayDocumentLayerTreeStructure: newDisplayDocumentLayerTreeStructure,
|
||||
DisplayEditableTextbox,
|
||||
DisplayRemoveEditableTextbox,
|
||||
UpdateDocumentLayer,
|
||||
UpdateActiveTool,
|
||||
UpdateActiveDocument,
|
||||
|
@ -404,6 +419,7 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
|||
DisplayDialogAboutGraphite,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerTextCommit,
|
||||
UpdateDocumentArtboards,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageConstructors;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { DocumentsState } from "@/state/documents";
|
|||
import { FullscreenState } from "@/state/fullscreen";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap;
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||
interface EventListenerTarget {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
|
@ -22,25 +22,29 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
|
||||
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onmodifyinputfiled(e) },
|
||||
];
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textInput = undefined as undefined | HTMLDivElement;
|
||||
|
||||
// Keyboard events
|
||||
|
||||
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const { target } = e;
|
||||
if (target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable)) return false;
|
||||
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialog.dialogIsVisible()) return false;
|
||||
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return false;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const { target } = e;
|
||||
if (key !== "escape" && !(key === "enter" && e.ctrlKey) && target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable))
|
||||
return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (key === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
|
@ -107,6 +111,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const { target } = e;
|
||||
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
|
||||
const inTextInput = target === textInput;
|
||||
|
||||
if (dialog.dialogIsVisible() && !inDialog) {
|
||||
dialog.dismissDialog();
|
||||
|
@ -114,7 +119,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (inCanvas) viewportPointerInteractionOngoing = true;
|
||||
if (textInput && !inTextInput) {
|
||||
editor.instance.on_change_text(textInput.textContent || "");
|
||||
} else if (inCanvas && !inTextInput) viewportPointerInteractionOngoing = true;
|
||||
|
||||
if (viewportPointerInteractionOngoing) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
|
@ -125,8 +132,19 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const onPointerUp = (e: PointerEvent): void => {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
if (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: PointerEvent): void => {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
|
@ -154,6 +172,10 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
}
|
||||
};
|
||||
|
||||
const onmodifyinputfiled = (e: CustomEvent): void => {
|
||||
textInput = e.detail;
|
||||
};
|
||||
|
||||
// Window events
|
||||
|
||||
const onWindowResize = (container: HTMLElement): void => {
|
||||
|
|
|
@ -274,6 +274,15 @@ impl JsEditorHandle {
|
|||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Mouse double clicked
|
||||
pub fn on_double_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {
|
||||
let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into());
|
||||
let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys");
|
||||
|
||||
let message = InputPreprocessorMessage::DoubleClick { editor_mouse_state, modifier_keys };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// A keyboard button depressed within screenspace the bounds of the viewport
|
||||
pub fn on_key_down(&self, name: String, modifiers: u8) {
|
||||
let key = translate_key(&name);
|
||||
|
@ -296,6 +305,22 @@ impl JsEditorHandle {
|
|||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// A text box was committed
|
||||
pub fn on_change_text(&self, new_text: String) -> Result<(), JsValue> {
|
||||
let message = TextMessage::TextChange { new_text };
|
||||
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 };
|
||||
self.dispatch(message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update primary color
|
||||
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
||||
let primary_color = match Color::from_rgbaf32(red, green, blue, alpha) {
|
||||
|
|
|
@ -18,3 +18,7 @@ kurbo = { git = "https://github.com/linebender/kurbo.git", features = [
|
|||
] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
glam = { version = "0.17", features = ["serde"] }
|
||||
|
||||
# Font rendering
|
||||
rustybuzz = "*"
|
||||
ttf-parser = "*" # Version from rustybuzz
|
||||
|
|
|
@ -4,6 +4,7 @@ use crate::layers::folder::Folder;
|
|||
use crate::layers::layer_info::{Layer, LayerData, LayerDataType};
|
||||
use crate::layers::simple_shape::Shape;
|
||||
use crate::layers::style::ViewMode;
|
||||
use crate::layers::text::Text;
|
||||
use crate::{DocumentError, DocumentResponse, Operation};
|
||||
|
||||
use glam::{DAffine2, DVec2};
|
||||
|
@ -113,7 +114,7 @@ impl Document {
|
|||
|
||||
Ok(match self.layer(common_prefix_of_path)?.data {
|
||||
LayerDataType::Folder(_) => common_prefix_of_path,
|
||||
LayerDataType::Shape(_) => &common_prefix_of_path[..common_prefix_of_path.len() - 1],
|
||||
_ => &common_prefix_of_path[..common_prefix_of_path.len() - 1],
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -256,6 +257,7 @@ impl Document {
|
|||
}
|
||||
}
|
||||
}
|
||||
LayerDataType::Text(_) => layer.cache_dirty = true,
|
||||
}
|
||||
layer.cache_dirty
|
||||
}
|
||||
|
@ -452,6 +454,32 @@ impl Document {
|
|||
|
||||
Some([vec![DocumentChanged, CreatedLayer { path: path.clone() }]].concat())
|
||||
}
|
||||
Operation::AddText {
|
||||
path,
|
||||
insert_index,
|
||||
transform,
|
||||
text,
|
||||
|
||||
style,
|
||||
size,
|
||||
} => {
|
||||
let layer = Layer::new(LayerDataType::Text(Text::new(text.clone(), *style, *size)), *transform);
|
||||
|
||||
self.set_layer(path, layer, *insert_index)?;
|
||||
|
||||
Some([vec![DocumentChanged, CreatedLayer { path: path.clone() }], update_thumbnails_upstream(path)].concat())
|
||||
}
|
||||
Operation::SetTextEditability { path, editable } => {
|
||||
self.layer_mut(path)?.as_text_mut()?.editable = *editable;
|
||||
self.mark_as_dirty(path)?;
|
||||
Some(vec![DocumentChanged])
|
||||
}
|
||||
Operation::SetTextContent { path, new_text } => {
|
||||
self.layer_mut(path)?.as_text_mut()?.update_text(new_text.clone());
|
||||
self.mark_as_dirty(path)?;
|
||||
|
||||
Some([vec![DocumentChanged], update_thumbnails_upstream(path)].concat())
|
||||
}
|
||||
Operation::AddNgon {
|
||||
path,
|
||||
insert_index,
|
||||
|
@ -587,11 +615,8 @@ impl Document {
|
|||
Operation::SetShapePath { path, bez_path } => {
|
||||
self.mark_as_dirty(path)?;
|
||||
|
||||
match &mut self.layer_mut(path)?.data {
|
||||
LayerDataType::Shape(shape) => {
|
||||
shape.path = bez_path.clone();
|
||||
}
|
||||
LayerDataType::Folder(_) => (),
|
||||
if let LayerDataType::Shape(shape) = &mut self.layer_mut(path)?.data {
|
||||
shape.path = bez_path.clone();
|
||||
}
|
||||
Some(vec![DocumentChanged, LayerChanged { path: path.clone() }])
|
||||
}
|
||||
|
@ -600,11 +625,13 @@ impl Document {
|
|||
self.set_transform_relative_to_viewport(path, transform)?;
|
||||
self.mark_as_dirty(path)?;
|
||||
|
||||
match &mut self.layer_mut(path)?.data {
|
||||
LayerDataType::Shape(shape) => {
|
||||
shape.path = bez_path.clone();
|
||||
}
|
||||
LayerDataType::Folder(_) => (),
|
||||
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(Shape::from_bez_path(bezpath, t.style, true));
|
||||
}
|
||||
|
||||
if let LayerDataType::Shape(shape) = &mut self.layer_mut(path)?.data {
|
||||
shape.path = bez_path.clone();
|
||||
}
|
||||
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(path)].concat())
|
||||
}
|
||||
|
|
|
@ -8,5 +8,6 @@ pub enum DocumentError {
|
|||
NotAFolder,
|
||||
NonReorderableSelection,
|
||||
NotAShape,
|
||||
NotText,
|
||||
InvalidFile(String),
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ use super::blend_mode::BlendMode;
|
|||
use super::folder::Folder;
|
||||
use super::simple_shape::Shape;
|
||||
use super::style::ViewMode;
|
||||
use super::text::Text;
|
||||
use crate::intersection::Quad;
|
||||
use crate::DocumentError;
|
||||
use crate::LayerId;
|
||||
|
@ -14,6 +15,7 @@ use std::fmt::Write;
|
|||
pub enum LayerDataType {
|
||||
Folder(Folder),
|
||||
Shape(Shape),
|
||||
Text(Text),
|
||||
}
|
||||
|
||||
impl LayerDataType {
|
||||
|
@ -21,6 +23,7 @@ impl LayerDataType {
|
|||
match self {
|
||||
LayerDataType::Shape(s) => s,
|
||||
LayerDataType::Folder(f) => f,
|
||||
LayerDataType::Text(t) => t,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,6 +31,7 @@ impl LayerDataType {
|
|||
match self {
|
||||
LayerDataType::Shape(s) => s,
|
||||
LayerDataType::Folder(f) => f,
|
||||
LayerDataType::Text(t) => t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -158,6 +162,20 @@ impl Layer {
|
|||
_ => Err(DocumentError::NotAFolder),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text_mut(&mut self) -> Result<&mut Text, DocumentError> {
|
||||
match &mut self.data {
|
||||
LayerDataType::Text(t) => Ok(t),
|
||||
_ => Err(DocumentError::NotText),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text(&self) -> Result<&Text, DocumentError> {
|
||||
match &self.data {
|
||||
LayerDataType::Text(t) => Ok(t),
|
||||
_ => Err(DocumentError::NotText),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Layer {
|
||||
|
|
|
@ -3,3 +3,4 @@ pub mod folder;
|
|||
pub mod layer_info;
|
||||
pub mod simple_shape;
|
||||
pub mod style;
|
||||
pub mod text;
|
||||
|
|
93
graphene/src/layers/text/SourceSansPro/OFL.txt
Normal file
93
graphene/src/layers/text/SourceSansPro/OFL.txt
Normal file
|
@ -0,0 +1,93 @@
|
|||
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.
|
BIN
graphene/src/layers/text/SourceSansPro/SourceSansPro-Regular.ttf
Normal file
BIN
graphene/src/layers/text/SourceSansPro/SourceSansPro-Regular.ttf
Normal file
Binary file not shown.
146
graphene/src/layers/text/mod.rs
Normal file
146
graphene/src/layers/text/mod.rs
Normal file
|
@ -0,0 +1,146 @@
|
|||
use super::layer_info::LayerData;
|
||||
use super::style::{self, PathStyle, ViewMode};
|
||||
use crate::intersection::{intersect_quad_bez_path, Quad};
|
||||
use crate::LayerId;
|
||||
|
||||
use glam::{DAffine2, DMat2, DVec2};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write;
|
||||
|
||||
mod to_kurbo;
|
||||
|
||||
fn glam_to_kurbo(transform: DAffine2) -> Affine {
|
||||
Affine::new(transform.to_cols_array())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Text {
|
||||
pub text: String,
|
||||
pub style: style::PathStyle,
|
||||
pub size: f64,
|
||||
pub line_width: Option<f64>,
|
||||
#[serde(skip)]
|
||||
pub editable: bool,
|
||||
#[serde(skip)]
|
||||
cached_path: Option<BezPath>,
|
||||
}
|
||||
|
||||
impl LayerData for Text {
|
||||
fn render(&mut self, svg: &mut String, transforms: &mut Vec<DAffine2>, view_mode: ViewMode) {
|
||||
let transform = self.transform(transforms, view_mode);
|
||||
let inverse = transform.inverse();
|
||||
if !inverse.is_finite() {
|
||||
let _ = write!(svg, "<!-- SVG shape has an invalid transform -->");
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(svg, r#"<g transform="matrix("#);
|
||||
inverse.to_cols_array().iter().enumerate().for_each(|(i, entry)| {
|
||||
let _ = svg.write_str(&(entry.to_string() + if i == 5 { "" } else { "," }));
|
||||
});
|
||||
let _ = svg.write_str(r#")">"#);
|
||||
|
||||
if self.editable {
|
||||
let _ = write!(
|
||||
svg,
|
||||
r#"<foreignObject transform="matrix({})"></foreignObject>"#,
|
||||
transform
|
||||
.to_cols_array()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| { entry.to_string() + if i == 5 { "" } else { "," } })
|
||||
.collect::<String>(),
|
||||
);
|
||||
} else {
|
||||
let mut path = self.to_bez_path();
|
||||
|
||||
path.apply_affine(glam_to_kurbo(transform));
|
||||
|
||||
let _ = write!(svg, r#"<path d="{}" {} />"#, path.to_svg(), self.style.render(view_mode));
|
||||
}
|
||||
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);
|
||||
|
||||
if transform.matrix2 == DMat2::ZERO {
|
||||
return None;
|
||||
}
|
||||
path.apply_affine(glam_to_kurbo(transform));
|
||||
|
||||
let kurbo::Rect { x0, y0, x1, y1 } = path.bounding_box();
|
||||
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) {
|
||||
intersections.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn transform(&self, transforms: &[DAffine2], mode: ViewMode) -> DAffine2 {
|
||||
let start = match mode {
|
||||
ViewMode::Outline => 0,
|
||||
_ => (transforms.len() as i32 - 1).max(0) as usize,
|
||||
};
|
||||
transforms.iter().skip(start).cloned().reduce(|a, b| a * b).unwrap_or(DAffine2::IDENTITY)
|
||||
}
|
||||
|
||||
pub fn new(text: String, style: PathStyle, size: f64) -> Self {
|
||||
let mut new = Self {
|
||||
text,
|
||||
style,
|
||||
size,
|
||||
line_width: None,
|
||||
editable: false,
|
||||
cached_path: None,
|
||||
};
|
||||
|
||||
new.regenerate_path();
|
||||
|
||||
new
|
||||
}
|
||||
|
||||
/// Converts to a BezPath, populating the cache if necessary
|
||||
#[inline]
|
||||
pub fn to_bez_path(&mut self) -> BezPath {
|
||||
if self.cached_path.is_none() {
|
||||
self.regenerate_path();
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn font_face() -> rustybuzz::Face<'static> {
|
||||
rustybuzz::Face::from_slice(include_bytes!("SourceSansPro/SourceSansPro-Regular.ttf"), 0).unwrap()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn generate_path(&self) -> BezPath {
|
||||
to_kurbo::to_kurbo(&self.text, Self::font_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);
|
||||
Rect::new(0., 0., far.x, far.y)
|
||||
}
|
||||
|
||||
pub fn regenerate_path(&mut self) {
|
||||
self.cached_path = Some(self.generate_path());
|
||||
}
|
||||
|
||||
pub fn update_text(&mut self, text: String) {
|
||||
self.text = text;
|
||||
self.regenerate_path();
|
||||
}
|
||||
}
|
143
graphene/src/layers/text/to_kurbo.rs
Normal file
143
graphene/src/layers/text/to_kurbo.rs
Normal file
|
@ -0,0 +1,143 @@
|
|||
use glam::DVec2;
|
||||
use kurbo::{BezPath, Point, Vec2};
|
||||
use rustybuzz::{GlyphBuffer, UnicodeBuffer};
|
||||
use ttf_parser::{GlyphId, OutlineBuilder};
|
||||
|
||||
struct Builder {
|
||||
path: BezPath,
|
||||
pos: Point,
|
||||
offset: Vec2,
|
||||
ascender: f64,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
impl OutlineBuilder for Builder {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.path.move_to(self.pos + self.offset + Vec2::new(x as f64, self.ascender - y as f64) * self.scale);
|
||||
}
|
||||
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.path.line_to(self.pos + self.offset + Vec2::new(x as f64, self.ascender - y as f64) * self.scale);
|
||||
}
|
||||
|
||||
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
|
||||
self.path.quad_to(
|
||||
self.pos + self.offset + Vec2::new(x1 as f64, self.ascender - y1 as f64) * self.scale,
|
||||
self.pos + self.offset + Vec2::new(x2 as f64, self.ascender - y2 as f64) * self.scale,
|
||||
);
|
||||
}
|
||||
|
||||
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
|
||||
self.path.curve_to(
|
||||
self.pos + self.offset + Vec2::new(x1 as f64, self.ascender - y1 as f64) * self.scale,
|
||||
self.pos + self.offset + Vec2::new(x2 as f64, self.ascender - y2 as f64) * self.scale,
|
||||
self.pos + self.offset + Vec2::new(x3 as f64, self.ascender - y3 as f64) * self.scale,
|
||||
);
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
fn font_properties(buzz_face: &rustybuzz::Face, font_size: f64) -> (f64, f64, UnicodeBuffer) {
|
||||
let scale = (buzz_face.units_per_em() as f64).recip() * font_size;
|
||||
let line_height = font_size;
|
||||
let buffer = UnicodeBuffer::new();
|
||||
(scale, line_height, buffer)
|
||||
}
|
||||
|
||||
fn push_str(buffer: &mut UnicodeBuffer, word: &str, trailing_space: bool) {
|
||||
buffer.push_str(word);
|
||||
|
||||
if trailing_space {
|
||||
buffer.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_word(line_width: Option<f64>, glyph_buffer: &GlyphBuffer, scale: f64, x_pos: f64) -> bool {
|
||||
if let Some(line_width) = line_width {
|
||||
let word_length: i32 = glyph_buffer.glyph_positions().iter().map(|pos| pos.x_advance).sum();
|
||||
let scaled_word_length = word_length as f64 * scale;
|
||||
|
||||
if scaled_word_length + x_pos > line_width {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn to_kurbo(str: &str, buzz_face: rustybuzz::Face, font_size: f64, line_width: Option<f64>) -> BezPath {
|
||||
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
|
||||
|
||||
let mut builder = Builder {
|
||||
path: BezPath::new(),
|
||||
pos: Point::ZERO,
|
||||
offset: Vec2::ZERO,
|
||||
ascender: buzz_face.ascender() as f64,
|
||||
scale,
|
||||
};
|
||||
|
||||
for line in str.split('\n') {
|
||||
let length = line.split(' ').count();
|
||||
for (index, word) in line.split(' ').enumerate() {
|
||||
push_str(&mut buffer, word, index != length - 1);
|
||||
|
||||
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
|
||||
|
||||
if wrap_word(line_width, &glyph_buffer, scale, builder.pos.x) {
|
||||
builder.pos = Point::new(0., builder.pos.y + line_height);
|
||||
}
|
||||
|
||||
for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) {
|
||||
if let Some(line_width) = line_width {
|
||||
if builder.pos.x + (glyph_position.x_advance as f64 * builder.scale) >= line_width {
|
||||
builder.pos = Point::new(0., builder.pos.y + line_height);
|
||||
}
|
||||
}
|
||||
builder.offset = Vec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) * builder.scale;
|
||||
buzz_face.outline_glyph(GlyphId(glyph_info.glyph_id as u16), &mut builder);
|
||||
builder.pos += Vec2::new(glyph_position.x_advance as f64, glyph_position.y_advance as f64) * builder.scale;
|
||||
}
|
||||
|
||||
buffer = glyph_buffer.clear();
|
||||
}
|
||||
builder.pos = Point::new(0., builder.pos.y + line_height);
|
||||
}
|
||||
builder.path
|
||||
}
|
||||
|
||||
pub fn bounding_box(str: &str, buzz_face: rustybuzz::Face, font_size: f64, line_width: Option<f64>) -> DVec2 {
|
||||
let (scale, line_height, mut buffer) = font_properties(&buzz_face, font_size);
|
||||
|
||||
let mut pos = DVec2::ZERO;
|
||||
let mut bounds = DVec2::ZERO;
|
||||
|
||||
for line in str.split('\n') {
|
||||
let length = line.split(' ').count();
|
||||
for (index, word) in line.split(' ').enumerate() {
|
||||
push_str(&mut buffer, word, index != length - 1);
|
||||
|
||||
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
|
||||
|
||||
if wrap_word(line_width, &glyph_buffer, scale, pos.x) {
|
||||
pos = DVec2::new(0., pos.y + line_height);
|
||||
}
|
||||
|
||||
for glyph_position in glyph_buffer.glyph_positions() {
|
||||
if let Some(line_width) = line_width {
|
||||
if pos.x + (glyph_position.x_advance as f64 * scale) >= line_width {
|
||||
pos = DVec2::new(0., pos.y + line_height);
|
||||
}
|
||||
}
|
||||
pos += DVec2::new(glyph_position.x_advance as f64, glyph_position.y_advance as f64) * scale;
|
||||
}
|
||||
bounds = bounds.max(pos + DVec2::new(0., line_height));
|
||||
|
||||
buffer = glyph_buffer.clear();
|
||||
}
|
||||
pos = DVec2::new(0., pos.y + line_height);
|
||||
}
|
||||
|
||||
bounds
|
||||
}
|
|
@ -45,6 +45,22 @@ pub enum Operation {
|
|||
transform: [f64; 6],
|
||||
style: style::PathStyle,
|
||||
},
|
||||
AddText {
|
||||
path: Vec<LayerId>,
|
||||
transform: [f64; 6],
|
||||
insert_index: isize,
|
||||
text: String,
|
||||
style: style::PathStyle,
|
||||
size: f64,
|
||||
},
|
||||
SetTextEditability {
|
||||
path: Vec<LayerId>,
|
||||
editable: bool,
|
||||
},
|
||||
SetTextContent {
|
||||
path: Vec<LayerId>,
|
||||
new_text: String,
|
||||
},
|
||||
AddPolyline {
|
||||
path: Vec<LayerId>,
|
||||
transform: [f64; 6],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue