Merge pull request #2595 from zitann2020/text/keep-empty-layer

fix(text): keep an empty Type-tool layer, as AE does
This commit is contained in:
BFlat
2026-10-30 04:09:42 +07:00
committed by GitHub
6 changed files with 23 additions and 28 deletions
+3 -3
View File
@@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
// script, MCP — leaves one final state.
// A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`,
// so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool
// layer survives, as it does in After Effects.
super::text_edit::end_edit(s, &Value::Null)?;
s.state.selected_layers.clear();
s.state.selected_props.clear();
+1 -1
View File
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
+9 -12
View File
@@ -71,7 +71,8 @@ pub struct TextEdit {
/// Character panel changes made with only a caret: the style the next typed text takes.
#[serde(default)]
pub pending: Option<CharStyle>,
/// The layer was made by a Type tool click; leaving it empty deletes it.
/// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this
/// only records how the edit began.
#[serde(default)]
pub created: bool,
/// Line position kept by repeated up / down moves (layout space).
@@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
/// `text.endEdit`: stop editing.
///
/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in
/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a
/// mistake to clean up. Deleting it took away something they could still type into.
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
s.edit("Delete Empty Text Layer", None, |proj, st| {
if let Some(c) = proj.comp_mut(cid) {
c.layers.retain(|l| l.id != e.layer);
}
st.selected_layers.retain(|l| *l != e.layer);
Ok(())
})?;
if s.state.text_edit.take().is_none() {
return Ok(Value::Null);
}
s.bump();
Ok(Value::Null)
+7 -9
View File
@@ -260,25 +260,25 @@ fn clipboard_and_paste_formatting() {
}
#[test]
fn type_tool_layers_point_box_and_empty_cleanup() {
fn type_tool_layers_point_box_and_empty_layer_is_kept() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
// Leaving an untouched Type-tool layer deletes it.
// Leaving an untouched Type-tool layer keeps it, as After Effects does.
s.execute("text.endEdit", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE");
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
let d = doc(&s, b);
assert_eq!(d.box_size, Some([300.0, 120.0]));
assert_eq!(d.box_pos, [-150.0, -60.0]);
let l = s.active_comp().unwrap().layers[0].clone();
let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone();
assert_eq!(l.name, "Boxed", "named after its text");
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
s.execute("text.endEdit", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included");
// Resizing the box (layer space) is an attribute too.
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
@@ -1202,7 +1202,7 @@ fn deselect_all_ends_the_open_edit() {
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
fn deselect_all_keeps_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
@@ -1214,7 +1214,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
+2 -2
View File
@@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() {
/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the
/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command:
/// typed characters change no text and Space plays. The Character panel keeps the edit, and a
/// text layer clicked into being and left empty goes away.
/// text layer clicked into being and left empty stays, as it does in After Effects.
#[test]
fn clicking_another_panel_stops_typing_into_text() {
for type_tool in [false, true] {
@@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() {
assert_eq!(layers(&h), 3, "the Type tool started a text layer");
let at_row = row(&h);
click(&mut h, at_row, Modifiers::NONE);
assert_eq!(layers(&h), 2, "left empty, it is removed");
assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects");
click(&mut h, at, Modifiers::NONE);
} else {
h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap();
+1 -1
View File
@@ -540,7 +540,7 @@ indices): character attributes
3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the
Character panel does the same); with an empty range it sets the style the next typed text takes.
4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly`
work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed).
work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does).
Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its
styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name