mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-29 04:08:12 +08: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:
committed by
Keavon Chambers
co-authored by
Keavon Chambers
parent
0da8dabba9
commit
a66920aa1c
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user