mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-07-23 15:45:05 +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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
|
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439989e6b8c38d1b6570a384ef1e49c8848128f5a97f3914baef02920842712f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
@ -110,7 +116,9 @@ dependencies = [
|
||||||
"glam",
|
"glam",
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"log",
|
"log",
|
||||||
|
"rustybuzz",
|
||||||
"serde",
|
"serde",
|
||||||
|
"ttf-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -280,6 +288,22 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
|
@ -341,6 +365,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
|
@ -390,6 +420,36 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
|
@ -115,6 +115,10 @@ pub enum DocumentMessage {
|
||||||
SetSnapping {
|
SetSnapping {
|
||||||
snap: bool,
|
snap: bool,
|
||||||
},
|
},
|
||||||
|
SetTexboxEditability {
|
||||||
|
path: Vec<LayerId>,
|
||||||
|
editable: bool,
|
||||||
|
},
|
||||||
SetViewMode {
|
SetViewMode {
|
||||||
view_mode: ViewMode,
|
view_mode: ViewMode,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::clipboards::Clipboard;
|
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::utility_types::{AlignAggregate, AlignAxis, DocumentSave, FlipAxis, VectorManipulatorSegment, VectorManipulatorShape};
|
||||||
use super::vectorize_layer_metadata;
|
use super::vectorize_layer_metadata;
|
||||||
use super::{ArtboardMessageHandler, MovementMessageHandler, OverlaysMessageHandler, TransformLayerMessageHandler};
|
use super::{ArtboardMessageHandler, MovementMessageHandler, OverlaysMessageHandler, TransformLayerMessageHandler};
|
||||||
|
@ -146,11 +146,11 @@ impl DocumentMessageHandler {
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let shape = match &layer.ok()?.data {
|
let (path, closed) = match &layer.ok()?.data {
|
||||||
LayerDataType::Shape(shape) => Some(shape),
|
LayerDataType::Shape(shape) => Some((shape.path.clone(), shape.closed)),
|
||||||
LayerDataType::Folder(_) => None,
|
LayerDataType::Text(text) => Some((text.to_bez_path_nonmut(), true)),
|
||||||
|
_ => None,
|
||||||
}?;
|
}?;
|
||||||
let path = shape.path.clone();
|
|
||||||
|
|
||||||
let segments = path
|
let segments = path
|
||||||
.segments()
|
.segments()
|
||||||
|
@ -170,7 +170,7 @@ impl DocumentMessageHandler {
|
||||||
path,
|
path,
|
||||||
segments,
|
segments,
|
||||||
transform: viewport_transform,
|
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]> {
|
pub fn visible_layers(&self) -> impl Iterator<Item = &[LayerId]> {
|
||||||
self.all_layers().filter(|path| match self.graphene_document.layer(path) {
|
self.all_layers().filter(|path| match self.graphene_document.layer(path) {
|
||||||
Ok(layer) => layer.visible,
|
Ok(layer) => layer.visible,
|
||||||
|
@ -216,17 +226,14 @@ impl DocumentMessageHandler {
|
||||||
for (id, layer) in folder.layer_ids.iter().zip(folder.layers()).rev() {
|
for (id, layer) in folder.layer_ids.iter().zip(folder.layers()).rev() {
|
||||||
data.push(*id);
|
data.push(*id);
|
||||||
space += 1;
|
space += 1;
|
||||||
match layer.data {
|
if let LayerDataType::Folder(ref folder) = layer.data {
|
||||||
LayerDataType::Shape(_) => (),
|
path.push(*id);
|
||||||
LayerDataType::Folder(ref folder) => {
|
if self.layer_metadata(path).expanded {
|
||||||
path.push(*id);
|
structure.push(space);
|
||||||
if self.layer_metadata(path).expanded {
|
self.serialize_structure(folder, structure, data, path);
|
||||||
structure.push(space);
|
space = 0;
|
||||||
self.serialize_structure(folder, structure, data, path);
|
|
||||||
space = 0;
|
|
||||||
}
|
|
||||||
path.pop();
|
|
||||||
}
|
}
|
||||||
|
path.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
structure.push(space | 1 << 63);
|
structure.push(space | 1 << 63);
|
||||||
|
@ -962,6 +969,22 @@ impl MessageHandler<DocumentMessage, &InputPreprocessorMessageHandler> for Docum
|
||||||
SetSnapping { snap } => {
|
SetSnapping { snap } => {
|
||||||
self.snapping_enabled = 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 } => {
|
SetViewMode { view_mode } => {
|
||||||
self.view_mode = view_mode;
|
self.view_mode = view_mode;
|
||||||
responses.push_front(DocumentMessage::DirtyRenderDocument.into());
|
responses.push_front(DocumentMessage::DirtyRenderDocument.into());
|
||||||
|
|
|
@ -97,6 +97,7 @@ pub struct LayerPanelEntry {
|
||||||
pub enum LayerDataTypeDiscriminant {
|
pub enum LayerDataTypeDiscriminant {
|
||||||
Folder,
|
Folder,
|
||||||
Shape,
|
Shape,
|
||||||
|
Text,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for LayerDataTypeDiscriminant {
|
impl fmt::Display for LayerDataTypeDiscriminant {
|
||||||
|
@ -104,6 +105,7 @@ impl fmt::Display for LayerDataTypeDiscriminant {
|
||||||
let name = match self {
|
let name = match self {
|
||||||
LayerDataTypeDiscriminant::Folder => "Folder",
|
LayerDataTypeDiscriminant::Folder => "Folder",
|
||||||
LayerDataTypeDiscriminant::Shape => "Shape",
|
LayerDataTypeDiscriminant::Shape => "Shape",
|
||||||
|
LayerDataTypeDiscriminant::Text => "Text",
|
||||||
};
|
};
|
||||||
|
|
||||||
formatter.write_str(name)
|
formatter.write_str(name)
|
||||||
|
@ -117,6 +119,7 @@ impl From<&LayerDataType> for LayerDataTypeDiscriminant {
|
||||||
match data {
|
match data {
|
||||||
Folder(_) => LayerDataTypeDiscriminant::Folder,
|
Folder(_) => LayerDataTypeDiscriminant::Folder,
|
||||||
Shape(_) => LayerDataTypeDiscriminant::Shape,
|
Shape(_) => LayerDataTypeDiscriminant::Shape,
|
||||||
|
Text(_) => LayerDataTypeDiscriminant::Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,12 +18,15 @@ pub enum FrontendMessage {
|
||||||
DisplayDialogError { title: String, description: String },
|
DisplayDialogError { title: String, description: String },
|
||||||
DisplayDialogPanic { panic_info: String, title: String, description: String },
|
DisplayDialogPanic { panic_info: String, title: String, description: String },
|
||||||
DisplayDocumentLayerTreeStructure { data_buffer: RawBuffer },
|
DisplayDocumentLayerTreeStructure { data_buffer: RawBuffer },
|
||||||
|
DisplayEditableTextbox { text: String, line_width: Option<f64>, font_size: f64 },
|
||||||
|
DisplayRemoveEditableTextbox,
|
||||||
|
|
||||||
// Trigger prefix: cause a browser API to do something
|
// Trigger prefix: cause a browser API to do something
|
||||||
TriggerFileDownload { document: String, name: String },
|
TriggerFileDownload { document: String, name: String },
|
||||||
TriggerFileUpload,
|
TriggerFileUpload,
|
||||||
TriggerIndexedDbRemoveDocument { document_id: u64 },
|
TriggerIndexedDbRemoveDocument { document_id: u64 },
|
||||||
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
|
TriggerIndexedDbWriteDocument { document: String, details: FrontendDocumentDetails, version: String },
|
||||||
|
TriggerTextCommit,
|
||||||
|
|
||||||
// Update prefix: give the frontend a new value or state for it to use
|
// Update prefix: give the frontend a new value or state for it to use
|
||||||
UpdateActiveDocument { document_id: u64 },
|
UpdateActiveDocument { document_id: u64 },
|
||||||
|
|
|
@ -14,4 +14,5 @@ pub enum MouseCursorIcon {
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
Grabbing,
|
Grabbing,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Text,
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ pub struct Mapping {
|
||||||
pub key_down: [KeyMappingEntries; NUMBER_OF_KEYS],
|
pub key_down: [KeyMappingEntries; NUMBER_OF_KEYS],
|
||||||
pub pointer_move: KeyMappingEntries,
|
pub pointer_move: KeyMappingEntries,
|
||||||
pub mouse_scroll: KeyMappingEntries,
|
pub mouse_scroll: KeyMappingEntries,
|
||||||
|
pub double_click: KeyMappingEntries,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Mapping {
|
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::MouseMove { snap_angle: KeyShift }, message=InputMapperMessage::PointerMove},
|
||||||
entry! {action=SelectMessage::DragStart { add_to_selection: KeyShift }, key_down=Lmb},
|
entry! {action=SelectMessage::DragStart { add_to_selection: KeyShift }, key_down=Lmb},
|
||||||
entry! {action=SelectMessage::DragStop, key_up=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=Rmb},
|
||||||
entry! {action=SelectMessage::Abort, key_down=KeyEscape},
|
entry! {action=SelectMessage::Abort, key_down=KeyEscape},
|
||||||
// Navigate
|
// Navigate
|
||||||
|
@ -59,6 +61,10 @@ impl Default for Mapping {
|
||||||
// Eyedropper
|
// Eyedropper
|
||||||
entry! {action=EyedropperMessage::LeftMouseDown, key_down=Lmb},
|
entry! {action=EyedropperMessage::LeftMouseDown, key_down=Lmb},
|
||||||
entry! {action=EyedropperMessage::RightMouseDown, key_down=Rmb},
|
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
|
// Rectangle
|
||||||
entry! {action=RectangleMessage::DragStart, key_down=Lmb},
|
entry! {action=RectangleMessage::DragStart, key_down=Lmb},
|
||||||
entry! {action=RectangleMessage::DragStop, key_up=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::Select }, key_down=KeyV},
|
||||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Navigate }, key_down=KeyZ},
|
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::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::Fill }, key_down=KeyF},
|
||||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Path }, key_down=KeyA},
|
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Path }, key_down=KeyA},
|
||||||
entry! {action=ToolMessage::ActivateTool { tool_type: ToolType::Pen }, key_down=KeyP},
|
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::LogDebug, key_down=Key2},
|
||||||
entry! {action=GlobalMessage::LogTrace, key_down=Key3},
|
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
|
// 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];
|
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 pointer_move);
|
||||||
sort(&mut mouse_scroll);
|
sort(&mut mouse_scroll);
|
||||||
|
sort(&mut double_click);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
key_up,
|
key_up,
|
||||||
key_down,
|
key_down,
|
||||||
pointer_move,
|
pointer_move,
|
||||||
mouse_scroll,
|
mouse_scroll,
|
||||||
|
double_click,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -243,6 +252,7 @@ impl Mapping {
|
||||||
let list = match message {
|
let list = match message {
|
||||||
KeyDown(key) => &self.key_down[key as usize],
|
KeyDown(key) => &self.key_down[key as usize],
|
||||||
KeyUp(key) => &self.key_up[key as usize],
|
KeyUp(key) => &self.key_up[key as usize],
|
||||||
|
DoubleClick => &self.double_click,
|
||||||
MouseScroll => &self.mouse_scroll,
|
MouseScroll => &self.mouse_scroll,
|
||||||
PointerMove => &self.pointer_move,
|
PointerMove => &self.pointer_move,
|
||||||
};
|
};
|
||||||
|
@ -331,6 +341,7 @@ mod input_mapper_macros {
|
||||||
let mut key_down = KeyMappingEntries::key_array();
|
let mut key_down = KeyMappingEntries::key_array();
|
||||||
let mut pointer_move: KeyMappingEntries = Default::default();
|
let mut pointer_move: KeyMappingEntries = Default::default();
|
||||||
let mut mouse_scroll: KeyMappingEntries = Default::default();
|
let mut mouse_scroll: KeyMappingEntries = Default::default();
|
||||||
|
let mut double_click: KeyMappingEntries = Default::default();
|
||||||
$(
|
$(
|
||||||
for entry in $entry {
|
for entry in $entry {
|
||||||
let arr = match entry.trigger {
|
let arr = match entry.trigger {
|
||||||
|
@ -338,11 +349,12 @@ mod input_mapper_macros {
|
||||||
InputMapperMessage::KeyUp(key) => &mut key_up[key as usize],
|
InputMapperMessage::KeyUp(key) => &mut key_up[key as usize],
|
||||||
InputMapperMessage::MouseScroll => &mut mouse_scroll,
|
InputMapperMessage::MouseScroll => &mut mouse_scroll,
|
||||||
InputMapperMessage::PointerMove => &mut pointer_move,
|
InputMapperMessage::PointerMove => &mut pointer_move,
|
||||||
|
InputMapperMessage::DoubleClick => &mut double_click,
|
||||||
};
|
};
|
||||||
arr.push(entry.clone());
|
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),
|
KeyUp(Key),
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
|
DoubleClick,
|
||||||
MouseScroll,
|
MouseScroll,
|
||||||
PointerMove,
|
PointerMove,
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum InputPreprocessorMessage {
|
pub enum InputPreprocessorMessage {
|
||||||
BoundsOfViewports { bounds_of_viewports: Vec<ViewportBounds> },
|
BoundsOfViewports { bounds_of_viewports: Vec<ViewportBounds> },
|
||||||
|
DoubleClick { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys },
|
||||||
KeyDown { key: Key, modifier_keys: ModifierKeys },
|
KeyDown { key: Key, modifier_keys: ModifierKeys },
|
||||||
KeyUp { key: Key, modifier_keys: ModifierKeys },
|
KeyUp { key: Key, modifier_keys: ModifierKeys },
|
||||||
MouseDown { editor_mouse_state: EditorMouseState, 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 } => {
|
InputPreprocessorMessage::KeyDown { key, modifier_keys } => {
|
||||||
self.handle_modifier_keys(modifier_keys, responses);
|
self.handle_modifier_keys(modifier_keys, responses);
|
||||||
self.keyboard.set(key as usize);
|
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::rectangle::{RectangleMessage, RectangleMessageDiscriminant};
|
||||||
pub use crate::viewport_tools::tools::select::{SelectMessage, SelectMessageDiscriminant};
|
pub use crate::viewport_tools::tools::select::{SelectMessage, SelectMessageDiscriminant};
|
||||||
pub use crate::viewport_tools::tools::shape::{ShapeMessage, ShapeMessageDiscriminant};
|
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 graphite_proc_macros::*;
|
||||||
|
|
||||||
pub use std::collections::VecDeque;
|
pub use std::collections::VecDeque;
|
||||||
|
|
|
@ -76,7 +76,7 @@ impl Default for ToolFsmState {
|
||||||
Crop => crop::Crop,
|
Crop => crop::Crop,
|
||||||
Navigate => navigate::Navigate,
|
Navigate => navigate::Navigate,
|
||||||
Eyedropper => eyedropper::Eyedropper,
|
Eyedropper => eyedropper::Eyedropper,
|
||||||
// Text => text::Text,
|
Text => text::Text,
|
||||||
Fill => fill::Fill,
|
Fill => fill::Fill,
|
||||||
// Gradient => gradient::Gradient,
|
// Gradient => gradient::Gradient,
|
||||||
// Brush => brush::Brush,
|
// Brush => brush::Brush,
|
||||||
|
@ -208,7 +208,7 @@ impl ToolType {
|
||||||
ToolType::Crop => ToolOptions::Crop {},
|
ToolType::Crop => ToolOptions::Crop {},
|
||||||
ToolType::Navigate => ToolOptions::Navigate {},
|
ToolType::Navigate => ToolOptions::Navigate {},
|
||||||
ToolType::Eyedropper => ToolOptions::Eyedropper {},
|
ToolType::Eyedropper => ToolOptions::Eyedropper {},
|
||||||
ToolType::Text => ToolOptions::Text {},
|
ToolType::Text => ToolOptions::Text { font_size: 14 },
|
||||||
ToolType::Fill => ToolOptions::Fill {},
|
ToolType::Fill => ToolOptions::Fill {},
|
||||||
ToolType::Gradient => ToolOptions::Gradient {},
|
ToolType::Gradient => ToolOptions::Gradient {},
|
||||||
ToolType::Brush => ToolOptions::Brush {},
|
ToolType::Brush => ToolOptions::Brush {},
|
||||||
|
@ -241,10 +241,10 @@ pub fn standard_tool_message(tool: ToolType, message_type: StandardToolMessageTy
|
||||||
match message_type {
|
match message_type {
|
||||||
StandardToolMessageType::DocumentIsDirty => match tool {
|
StandardToolMessageType::DocumentIsDirty => match tool {
|
||||||
ToolType::Select => Some(SelectMessage::DocumentIsDirty.into()),
|
ToolType::Select => Some(SelectMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Crop => None, // Some(CropMessage::DocumentIsDirty.into()),
|
ToolType::Crop => None, // Some(CropMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Navigate => None, // Some(NavigateMessage::DocumentIsDirty.into()),
|
ToolType::Navigate => None, // Some(NavigateMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Eyedropper => None, // Some(EyedropperMessage::DocumentIsDirty.into()),
|
ToolType::Eyedropper => None, // Some(EyedropperMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Text => None, // Some(TextMessage::DocumentIsDirty.into()),
|
ToolType::Text => Some(TextMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Fill => None, // Some(FillMessage::DocumentIsDirty.into()),
|
ToolType::Fill => None, // Some(FillMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Gradient => None, // Some(GradientMessage::DocumentIsDirty.into()),
|
ToolType::Gradient => None, // Some(GradientMessage::DocumentIsDirty.into()),
|
||||||
ToolType::Brush => None, // Some(BrushMessage::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::Crop => Some(CropMessage::Abort.into()),
|
||||||
ToolType::Navigate => Some(NavigateMessage::Abort.into()),
|
ToolType::Navigate => Some(NavigateMessage::Abort.into()),
|
||||||
ToolType::Eyedropper => Some(EyedropperMessage::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::Fill => Some(FillMessage::Abort.into()),
|
||||||
// ToolType::Gradient => Some(GradientMessage::Abort.into()),
|
// ToolType::Gradient => Some(GradientMessage::Abort.into()),
|
||||||
// ToolType::Brush => Some(BrushMessage::Abort.into()),
|
// ToolType::Brush => Some(BrushMessage::Abort.into()),
|
||||||
|
@ -297,7 +297,7 @@ pub fn message_to_tool_type(message: &ToolMessage) -> ToolType {
|
||||||
Crop(_) => ToolType::Crop,
|
Crop(_) => ToolType::Crop,
|
||||||
Navigate(_) => ToolType::Navigate,
|
Navigate(_) => ToolType::Navigate,
|
||||||
Eyedropper(_) => ToolType::Eyedropper,
|
Eyedropper(_) => ToolType::Eyedropper,
|
||||||
// Text(_) => ToolType::Text,
|
Text(_) => ToolType::Text,
|
||||||
Fill(_) => ToolType::Fill,
|
Fill(_) => ToolType::Fill,
|
||||||
// Gradient(_) => ToolType::Gradient,
|
// Gradient(_) => ToolType::Gradient,
|
||||||
// Brush(_) => ToolType::Brush,
|
// Brush(_) => ToolType::Brush,
|
||||||
|
|
|
@ -28,6 +28,9 @@ pub enum ToolMessage {
|
||||||
// Text(TextMessage),
|
// Text(TextMessage),
|
||||||
#[remain::unsorted]
|
#[remain::unsorted]
|
||||||
#[child]
|
#[child]
|
||||||
|
Text(TextMessage),
|
||||||
|
#[remain::unsorted]
|
||||||
|
#[child]
|
||||||
Fill(FillMessage),
|
Fill(FillMessage),
|
||||||
// #[remain::unsorted]
|
// #[remain::unsorted]
|
||||||
// #[child]
|
// #[child]
|
||||||
|
|
|
@ -6,7 +6,7 @@ pub enum ToolOptions {
|
||||||
Crop {},
|
Crop {},
|
||||||
Navigate {},
|
Navigate {},
|
||||||
Eyedropper {},
|
Eyedropper {},
|
||||||
Text {},
|
Text { font_size: u32 },
|
||||||
Fill {},
|
Fill {},
|
||||||
Gradient {},
|
Gradient {},
|
||||||
Brush {},
|
Brush {},
|
||||||
|
|
|
@ -11,3 +11,4 @@ pub mod rectangle;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
pub mod text;
|
||||||
|
|
|
@ -8,7 +8,7 @@ use crate::input::InputPreprocessorMessageHandler;
|
||||||
use crate::message_prelude::*;
|
use crate::message_prelude::*;
|
||||||
use crate::misc::{HintData, HintGroup, HintInfo, KeysGroup};
|
use crate::misc::{HintData, HintGroup, HintInfo, KeysGroup};
|
||||||
use crate::viewport_tools::snapping::SnapHandler;
|
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::document::Document;
|
||||||
use graphene::intersection::Quad;
|
use graphene::intersection::Quad;
|
||||||
|
@ -43,6 +43,7 @@ pub enum SelectMessage {
|
||||||
add_to_selection: Key,
|
add_to_selection: Key,
|
||||||
},
|
},
|
||||||
DragStop,
|
DragStop,
|
||||||
|
EditText,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
FlipVertical,
|
FlipVertical,
|
||||||
MouseMove {
|
MouseMove {
|
||||||
|
@ -75,9 +76,9 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for Select {
|
||||||
use SelectToolFsmState::*;
|
use SelectToolFsmState::*;
|
||||||
|
|
||||||
match self.fsm_state {
|
match self.fsm_state {
|
||||||
Ready => actions!(SelectMessageDiscriminant; DragStart),
|
Ready => actions!(SelectMessageDiscriminant; DragStart, EditText),
|
||||||
Dragging => actions!(SelectMessageDiscriminant; DragStop, MouseMove),
|
Dragging => actions!(SelectMessageDiscriminant; DragStop, MouseMove, EditText),
|
||||||
DrawingBox => actions!(SelectMessageDiscriminant; DragStop, MouseMove, Abort),
|
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));
|
buffer.into_iter().rev().for_each(|message| responses.push_front(message));
|
||||||
self
|
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 }) => {
|
(Ready, DragStart { add_to_selection }) => {
|
||||||
data.drag_start = input.mouse.position;
|
data.drag_start = input.mouse.position;
|
||||||
data.drag_current = 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,
|
dialog: this.dialog,
|
||||||
documents: this.documents,
|
documents: this.documents,
|
||||||
fullscreen: this.fullscreen,
|
fullscreen: this.fullscreen,
|
||||||
|
inputManager: this.inputManager,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
@ -78,7 +78,7 @@
|
||||||
|
|
||||||
<Separator :type="'Section'" :direction="'Vertical'" />
|
<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="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
||||||
<ShelfItemInput
|
<ShelfItemInput
|
||||||
icon="ParametricGradientTool"
|
icon="ParametricGradientTool"
|
||||||
|
@ -246,6 +246,32 @@
|
||||||
pointer-events: auto;
|
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>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent, nextTick } from "vue";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UpdateDocumentArtwork,
|
UpdateDocumentArtwork,
|
||||||
|
@ -266,6 +292,9 @@ import {
|
||||||
ToolName,
|
ToolName,
|
||||||
UpdateDocumentArtboards,
|
UpdateDocumentArtboards,
|
||||||
UpdateMouseCursor,
|
UpdateMouseCursor,
|
||||||
|
TriggerTextCommit,
|
||||||
|
DisplayRemoveEditableTextbox,
|
||||||
|
DisplayEditableTextbox,
|
||||||
} from "@/dispatcher/js-messages";
|
} from "@/dispatcher/js-messages";
|
||||||
|
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
|
@ -347,13 +376,46 @@ export default defineComponent({
|
||||||
this.editor.instance.reset_colors();
|
this.editor.instance.reset_colors();
|
||||||
},
|
},
|
||||||
canvasPointerDown(e: PointerEvent) {
|
canvasPointerDown(e: PointerEvent) {
|
||||||
const canvas = this.$refs.canvas as HTMLElement;
|
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
|
||||||
canvas.setPointerCapture(e.pointerId);
|
if (!onEditbox) {
|
||||||
|
const canvas = this.$refs.canvas as HTMLElement;
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
||||||
this.artworkSvg = UpdateDocumentArtwork.svg;
|
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) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
|
||||||
|
@ -393,6 +455,31 @@ export default defineComponent({
|
||||||
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
||||||
this.canvasCursor = updateMouseCursor.cursor;
|
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("resize", this.viewportResize);
|
||||||
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
||||||
|
@ -435,6 +522,7 @@ export default defineComponent({
|
||||||
rulerOrigin: { x: 0, y: 0 },
|
rulerOrigin: { x: 0, y: 0 },
|
||||||
rulerSpacing: 100,
|
rulerSpacing: 100,
|
||||||
rulerInterval: 100,
|
rulerInterval: 100,
|
||||||
|
textInput: undefined as undefined | HTMLDivElement,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|
|
@ -154,7 +154,7 @@ export default defineComponent({
|
||||||
Crop: [],
|
Crop: [],
|
||||||
Navigate: [],
|
Navigate: [],
|
||||||
Eyedropper: [],
|
Eyedropper: [],
|
||||||
Text: [],
|
Text: [{ kind: "NumberInput", optionPath: ["font_size"], props: { min: 1, isInteger: true, unit: " px", label: "Font size" } }],
|
||||||
Fill: [],
|
Fill: [],
|
||||||
Gradient: [],
|
Gradient: [],
|
||||||
Brush: [],
|
Brush: [],
|
||||||
|
|
|
@ -201,7 +201,7 @@ export class UpdateDocumentRulers extends JsMessage {
|
||||||
readonly interval!: number;
|
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 ToCssCursorProperty = Transform(({ value }) => {
|
||||||
const cssNames: Record<string, MouseCursorIcon> = {
|
const cssNames: Record<string, MouseCursorIcon> = {
|
||||||
|
@ -209,6 +209,7 @@ const ToCssCursorProperty = Transform(({ value }) => {
|
||||||
ZoomOut: "zoom-out",
|
ZoomOut: "zoom-out",
|
||||||
Grabbing: "grabbing",
|
Grabbing: "grabbing",
|
||||||
Crosshair: "crosshair",
|
Crosshair: "crosshair",
|
||||||
|
Text: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
return cssNames[value] || "default";
|
return cssNames[value] || "default";
|
||||||
|
@ -294,6 +295,16 @@ export function newDisplayDocumentLayerTreeStructure(input: { data_buffer: DataB
|
||||||
return currentFolder;
|
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 {
|
export class UpdateDocumentLayer extends JsMessage {
|
||||||
@Type(() => LayerPanelEntry)
|
@Type(() => LayerPanelEntry)
|
||||||
readonly data!: LayerPanelEntry;
|
readonly data!: LayerPanelEntry;
|
||||||
|
@ -375,6 +386,8 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
||||||
document_id!: string;
|
document_id!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TriggerTextCommit extends JsMessage {}
|
||||||
|
|
||||||
// Any is used since the type of the object should be known from the rust side
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage;
|
type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage;
|
||||||
|
@ -388,6 +401,8 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
||||||
TriggerFileDownload,
|
TriggerFileDownload,
|
||||||
TriggerFileUpload,
|
TriggerFileUpload,
|
||||||
DisplayDocumentLayerTreeStructure: newDisplayDocumentLayerTreeStructure,
|
DisplayDocumentLayerTreeStructure: newDisplayDocumentLayerTreeStructure,
|
||||||
|
DisplayEditableTextbox,
|
||||||
|
DisplayRemoveEditableTextbox,
|
||||||
UpdateDocumentLayer,
|
UpdateDocumentLayer,
|
||||||
UpdateActiveTool,
|
UpdateActiveTool,
|
||||||
UpdateActiveDocument,
|
UpdateActiveDocument,
|
||||||
|
@ -404,6 +419,7 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
||||||
DisplayDialogAboutGraphite,
|
DisplayDialogAboutGraphite,
|
||||||
TriggerIndexedDbWriteDocument,
|
TriggerIndexedDbWriteDocument,
|
||||||
TriggerIndexedDbRemoveDocument,
|
TriggerIndexedDbRemoveDocument,
|
||||||
|
TriggerTextCommit,
|
||||||
UpdateDocumentArtboards,
|
UpdateDocumentArtboards,
|
||||||
} as const;
|
} as const;
|
||||||
export type JsMessageType = keyof typeof messageConstructors;
|
export type JsMessageType = keyof typeof messageConstructors;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { DocumentsState } from "@/state/documents";
|
||||||
import { FullscreenState } from "@/state/fullscreen";
|
import { FullscreenState } from "@/state/fullscreen";
|
||||||
import { EditorState } from "@/state/wasm-loader";
|
import { EditorState } from "@/state/wasm-loader";
|
||||||
|
|
||||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap;
|
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||||
interface EventListenerTarget {
|
interface EventListenerTarget {
|
||||||
addEventListener: typeof window.addEventListener;
|
addEventListener: typeof window.addEventListener;
|
||||||
removeEventListener: typeof window.removeEventListener;
|
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: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
|
||||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
|
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
|
||||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(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: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
|
||||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
|
{ 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 viewportPointerInteractionOngoing = false;
|
||||||
|
let textInput = undefined as undefined | HTMLDivElement;
|
||||||
|
|
||||||
// Keyboard events
|
// Keyboard events
|
||||||
|
|
||||||
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
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
|
// Don't redirect when a modal is covering the workspace
|
||||||
if (dialog.dialogIsVisible()) return false;
|
if (dialog.dialogIsVisible()) return false;
|
||||||
|
|
||||||
const key = getLatinKey(e);
|
const key = getLatinKey(e);
|
||||||
if (!key) return false;
|
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
|
// Don't redirect a fullscreen request
|
||||||
if (key === "f11" && e.type === "keydown" && !e.repeat) {
|
if (key === "f11" && e.type === "keydown" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -107,6 +111,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
||||||
const { target } = e;
|
const { target } = e;
|
||||||
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||||
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
|
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
|
||||||
|
const inTextInput = target === textInput;
|
||||||
|
|
||||||
if (dialog.dialogIsVisible() && !inDialog) {
|
if (dialog.dialogIsVisible() && !inDialog) {
|
||||||
dialog.dismissDialog();
|
dialog.dismissDialog();
|
||||||
|
@ -114,7 +119,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCanvas) viewportPointerInteractionOngoing = true;
|
if (textInput && !inTextInput) {
|
||||||
|
editor.instance.on_change_text(textInput.textContent || "");
|
||||||
|
} else if (inCanvas && !inTextInput) viewportPointerInteractionOngoing = true;
|
||||||
|
|
||||||
if (viewportPointerInteractionOngoing) {
|
if (viewportPointerInteractionOngoing) {
|
||||||
const modifiers = makeModifiersBitfield(e);
|
const modifiers = makeModifiersBitfield(e);
|
||||||
|
@ -125,8 +132,19 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
||||||
const onPointerUp = (e: PointerEvent): void => {
|
const onPointerUp = (e: PointerEvent): void => {
|
||||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||||
|
|
||||||
const modifiers = makeModifiersBitfield(e);
|
if (!textInput) {
|
||||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
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
|
// Mouse events
|
||||||
|
@ -154,6 +172,10 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onmodifyinputfiled = (e: CustomEvent): void => {
|
||||||
|
textInput = e.detail;
|
||||||
|
};
|
||||||
|
|
||||||
// Window events
|
// Window events
|
||||||
|
|
||||||
const onWindowResize = (container: HTMLElement): void => {
|
const onWindowResize = (container: HTMLElement): void => {
|
||||||
|
|
|
@ -274,6 +274,15 @@ impl JsEditorHandle {
|
||||||
self.dispatch(message);
|
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
|
/// A keyboard button depressed within screenspace the bounds of the viewport
|
||||||
pub fn on_key_down(&self, name: String, modifiers: u8) {
|
pub fn on_key_down(&self, name: String, modifiers: u8) {
|
||||||
let key = translate_key(&name);
|
let key = translate_key(&name);
|
||||||
|
@ -296,6 +305,22 @@ impl JsEditorHandle {
|
||||||
self.dispatch(message);
|
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
|
/// Update primary color
|
||||||
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
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) {
|
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"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
glam = { version = "0.17", features = ["serde"] }
|
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::layer_info::{Layer, LayerData, LayerDataType};
|
||||||
use crate::layers::simple_shape::Shape;
|
use crate::layers::simple_shape::Shape;
|
||||||
use crate::layers::style::ViewMode;
|
use crate::layers::style::ViewMode;
|
||||||
|
use crate::layers::text::Text;
|
||||||
use crate::{DocumentError, DocumentResponse, Operation};
|
use crate::{DocumentError, DocumentResponse, Operation};
|
||||||
|
|
||||||
use glam::{DAffine2, DVec2};
|
use glam::{DAffine2, DVec2};
|
||||||
|
@ -113,7 +114,7 @@ impl Document {
|
||||||
|
|
||||||
Ok(match self.layer(common_prefix_of_path)?.data {
|
Ok(match self.layer(common_prefix_of_path)?.data {
|
||||||
LayerDataType::Folder(_) => common_prefix_of_path,
|
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
|
layer.cache_dirty
|
||||||
}
|
}
|
||||||
|
@ -452,6 +454,32 @@ impl Document {
|
||||||
|
|
||||||
Some([vec![DocumentChanged, CreatedLayer { path: path.clone() }]].concat())
|
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 {
|
Operation::AddNgon {
|
||||||
path,
|
path,
|
||||||
insert_index,
|
insert_index,
|
||||||
|
@ -587,11 +615,8 @@ impl Document {
|
||||||
Operation::SetShapePath { path, bez_path } => {
|
Operation::SetShapePath { path, bez_path } => {
|
||||||
self.mark_as_dirty(path)?;
|
self.mark_as_dirty(path)?;
|
||||||
|
|
||||||
match &mut self.layer_mut(path)?.data {
|
if let LayerDataType::Shape(shape) = &mut self.layer_mut(path)?.data {
|
||||||
LayerDataType::Shape(shape) => {
|
shape.path = bez_path.clone();
|
||||||
shape.path = bez_path.clone();
|
|
||||||
}
|
|
||||||
LayerDataType::Folder(_) => (),
|
|
||||||
}
|
}
|
||||||
Some(vec![DocumentChanged, LayerChanged { path: path.clone() }])
|
Some(vec![DocumentChanged, LayerChanged { path: path.clone() }])
|
||||||
}
|
}
|
||||||
|
@ -600,11 +625,13 @@ impl Document {
|
||||||
self.set_transform_relative_to_viewport(path, transform)?;
|
self.set_transform_relative_to_viewport(path, transform)?;
|
||||||
self.mark_as_dirty(path)?;
|
self.mark_as_dirty(path)?;
|
||||||
|
|
||||||
match &mut self.layer_mut(path)?.data {
|
if let LayerDataType::Text(t) = &mut self.layer_mut(path)?.data {
|
||||||
LayerDataType::Shape(shape) => {
|
let bezpath = t.to_bez_path();
|
||||||
shape.path = bez_path.clone();
|
self.layer_mut(path)?.data = layers::layer_info::LayerDataType::Shape(Shape::from_bez_path(bezpath, t.style, true));
|
||||||
}
|
}
|
||||||
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() }], update_thumbnails_upstream(path)].concat())
|
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(path)].concat())
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,5 +8,6 @@ pub enum DocumentError {
|
||||||
NotAFolder,
|
NotAFolder,
|
||||||
NonReorderableSelection,
|
NonReorderableSelection,
|
||||||
NotAShape,
|
NotAShape,
|
||||||
|
NotText,
|
||||||
InvalidFile(String),
|
InvalidFile(String),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ use super::blend_mode::BlendMode;
|
||||||
use super::folder::Folder;
|
use super::folder::Folder;
|
||||||
use super::simple_shape::Shape;
|
use super::simple_shape::Shape;
|
||||||
use super::style::ViewMode;
|
use super::style::ViewMode;
|
||||||
|
use super::text::Text;
|
||||||
use crate::intersection::Quad;
|
use crate::intersection::Quad;
|
||||||
use crate::DocumentError;
|
use crate::DocumentError;
|
||||||
use crate::LayerId;
|
use crate::LayerId;
|
||||||
|
@ -14,6 +15,7 @@ use std::fmt::Write;
|
||||||
pub enum LayerDataType {
|
pub enum LayerDataType {
|
||||||
Folder(Folder),
|
Folder(Folder),
|
||||||
Shape(Shape),
|
Shape(Shape),
|
||||||
|
Text(Text),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayerDataType {
|
impl LayerDataType {
|
||||||
|
@ -21,6 +23,7 @@ impl LayerDataType {
|
||||||
match self {
|
match self {
|
||||||
LayerDataType::Shape(s) => s,
|
LayerDataType::Shape(s) => s,
|
||||||
LayerDataType::Folder(f) => f,
|
LayerDataType::Folder(f) => f,
|
||||||
|
LayerDataType::Text(t) => t,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +31,7 @@ impl LayerDataType {
|
||||||
match self {
|
match self {
|
||||||
LayerDataType::Shape(s) => s,
|
LayerDataType::Shape(s) => s,
|
||||||
LayerDataType::Folder(f) => f,
|
LayerDataType::Folder(f) => f,
|
||||||
|
LayerDataType::Text(t) => t,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -158,6 +162,20 @@ impl Layer {
|
||||||
_ => Err(DocumentError::NotAFolder),
|
_ => 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 {
|
impl Clone for Layer {
|
||||||
|
|
|
@ -3,3 +3,4 @@ pub mod folder;
|
||||||
pub mod layer_info;
|
pub mod layer_info;
|
||||||
pub mod simple_shape;
|
pub mod simple_shape;
|
||||||
pub mod style;
|
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],
|
transform: [f64; 6],
|
||||||
style: style::PathStyle,
|
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 {
|
AddPolyline {
|
||||||
path: Vec<LayerId>,
|
path: Vec<LayerId>,
|
||||||
transform: [f64; 6],
|
transform: [f64; 6],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue