mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 12:29:34 -06:00
397 lines
17 KiB
Rust
397 lines
17 KiB
Rust
use super::autocorrect::undo_auto_list;
|
|
use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed};
|
|
use super::ime::{composition_range, model_pos, walk_backward, walk_forward};
|
|
use super::layout::{ctx_for, fit_text_box, layout_for, pos_param};
|
|
use super::{
|
|
DocState, HistoryEntry, Pos, Result, Session, ShapeId, ShapeKind, TextBody, Value, bad, body_of, bool_or, ed, edit_shapes, edit_text, id_param,
|
|
json, ok, push_undo, str_param, xfrm_of,
|
|
};
|
|
|
|
/// A table cell's text laid out the way it is drawn, mapped into slide coordinates.
|
|
pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<CellText> {
|
|
let sh = st.shape(id)?;
|
|
let ShapeKind::Table(tb) = &sh.kind else { return None };
|
|
let cell = tb.cell(row, col)?;
|
|
let ctx = ctx_for(&st.doc, &st.selection)?;
|
|
let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb);
|
|
let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2;
|
|
let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
|
|
if body.paragraphs.is_empty() {
|
|
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
|
}
|
|
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
|
let layout = deckcraft_text::layout(
|
|
&ctx,
|
|
&tmp,
|
|
&body,
|
|
&deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
|
);
|
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
|
let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation);
|
|
Some(CellText { layout, to_slide })
|
|
}
|
|
|
|
pub(super) struct CellText {
|
|
pub(super) layout: deckcraft_text::TextLayout,
|
|
pub(super) to_slide: deckcraft_geom::Affine,
|
|
}
|
|
|
|
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
|
insert_text(s, &text, None)
|
|
}
|
|
|
|
pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
|
if dir == "backward" && undo_auto_list(s)? {
|
|
return ok();
|
|
}
|
|
let dir = dir.to_string();
|
|
edit_text(s, move |body, t| {
|
|
let (a, b) = t.ordered();
|
|
let at = if dir == "wordBackward" || dir == "wordForward" {
|
|
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
|
// (PowerPoint for Windows).
|
|
ed::delete_word(body, a, dir == "wordForward")
|
|
} else if a != b {
|
|
ed::delete(body, a, b)
|
|
} else {
|
|
let other = match dir.as_str() {
|
|
"forward" => ed::char_move(body, a, true),
|
|
_ => {
|
|
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
|
if a.1 == 0
|
|
&& let Some(para) = body.paragraphs.get_mut(a.0)
|
|
&& para.level > 0
|
|
{
|
|
para.level -= 1;
|
|
t.anchor = a;
|
|
t.caret = a;
|
|
return;
|
|
}
|
|
ed::char_move(body, a, false)
|
|
}
|
|
};
|
|
ed::delete(body, a, other)
|
|
};
|
|
t.anchor = at;
|
|
t.caret = at;
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
/// A `before`/`after` count from a control or input-method request.
|
|
///
|
|
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
|
|
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
|
|
/// would delete different text than asked.
|
|
pub(super) fn count_param(p: &Value, name: &str) -> usize {
|
|
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
|
|
}
|
|
|
|
/// Delete at most the requested character or byte counts around the selection or pre-edit,
|
|
/// preserving selection direction and composition boundaries and recording one undo step
|
|
/// outside composition.
|
|
pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let before = count_param(p, "before");
|
|
let after = count_param(p, "after");
|
|
let bytes = bool_or(p, "bytes", false);
|
|
let st = s.doc()?;
|
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
|
|
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
|
|
let comp = composition_range(body, st.composition.as_ref());
|
|
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
|
|
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
|
|
let ordered = {
|
|
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
|
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
|
if a <= b { (a, b) } else { (b, a) }
|
|
};
|
|
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
|
|
// scalars fully inside the requested before/after ranges around it are removed.
|
|
let (start, end) = match comp {
|
|
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
|
|
None => ordered,
|
|
};
|
|
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
|
|
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
|
|
let deleted = before_chars + after_chars;
|
|
if deleted == 0 {
|
|
return Ok(json!({"deleted": 0}));
|
|
}
|
|
let before_doc = st.doc.clone();
|
|
let before_sel = st.selection.clone();
|
|
let composing = st.composition.is_some();
|
|
let vertex_kind = s.tool.vertex_kind.clone();
|
|
edit_text(s, |body, t| {
|
|
// Delete the after-range first, so the positions of the earlier range stay valid; then
|
|
// the text before the selection.
|
|
if after_chars > 0 {
|
|
ed::delete(body, end, to);
|
|
}
|
|
if before_chars > 0 {
|
|
ed::delete(body, from, start);
|
|
}
|
|
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
|
|
t.caret = remap_after_delete(t.caret, start, from, before_chars);
|
|
t.line_end = None;
|
|
})?;
|
|
if composing {
|
|
if let Some(c) = s.doc_mut()?.composition.as_mut() {
|
|
c.start = if before_chars == 0 {
|
|
start
|
|
} else if from.0 == start.0 {
|
|
(start.0, start.1 - before_chars)
|
|
} else {
|
|
from
|
|
};
|
|
}
|
|
} else {
|
|
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
|
|
}
|
|
Ok(json!({"deleted": deleted}))
|
|
}
|
|
|
|
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
|
|
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
|
|
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
|
|
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
|
|
if before_chars == 0 || pos < start {
|
|
return pos;
|
|
}
|
|
if from.0 == start.0 {
|
|
// Only endpoints in the start paragraph move; later paragraphs keep their text.
|
|
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
|
|
} else {
|
|
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
|
|
}
|
|
}
|
|
|
|
pub(super) fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
|
}
|
|
|
|
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
|
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
|
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
|
Some(line) => line.rtl,
|
|
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
|
}
|
|
}
|
|
|
|
pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|
use deckcraft_text::Affinity;
|
|
let to = str_param(p, "to").unwrap_or("right").to_string();
|
|
let extend = bool_or(p, "extend", false);
|
|
let st = s.doc()?;
|
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.move", "not editing text"))?;
|
|
let body = body_of(st, &t).cloned().unwrap_or_default();
|
|
let layout = layout_for(st, &t);
|
|
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
|
let caret = t.caret;
|
|
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
|
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
|
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
|
let to = match (to.as_str(), rtl) {
|
|
("left", true) => "right",
|
|
("right", true) => "left",
|
|
("wordLeft", true) => "wordRight",
|
|
("wordRight", true) => "wordLeft",
|
|
(x, _) => x,
|
|
}
|
|
.to_string();
|
|
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
|
let mut upstream = false;
|
|
let new = match to.as_str() {
|
|
"left" if t.is_range() && !extend => collapse_to(true),
|
|
"right" if t.is_range() && !extend => collapse_to(false),
|
|
"left" => ed::char_move(&body, caret, false),
|
|
"right" => ed::char_move(&body, caret, true),
|
|
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
|
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
|
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
|
"paraStart" => (caret.0, 0),
|
|
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
|
"paraUp" => ed::para_up(&body, caret),
|
|
"paraDown" => ed::para_down(&body, caret),
|
|
"start" => (0, 0),
|
|
"end" => ed::end(&body),
|
|
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
|
Some(l) => {
|
|
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
|
let (r, a) = match to.as_str() {
|
|
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
|
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
|
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
|
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
|
_ => l.line_end_caret(lp(caret), aff),
|
|
};
|
|
upstream = a == Affinity::Upstream;
|
|
(r.para, r.ch)
|
|
}
|
|
None => match to.as_str() {
|
|
"up" => (caret.0.saturating_sub(1), caret.1),
|
|
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
|
"lineStart" => (caret.0, 0),
|
|
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
|
},
|
|
},
|
|
other => return Err(bad("text.move", format!("unknown direction `{other}`"))),
|
|
};
|
|
let new = (new.0.min(body.paragraphs.len().saturating_sub(1)), new.1.min(body.paragraphs.get(new.0).map(|x| x.char_len()).unwrap_or(0)));
|
|
s.select(|_, sel| {
|
|
if let Some(tt) = sel.text.as_mut() {
|
|
tt.caret = new;
|
|
tt.line_end = if upstream { Some(new) } else { None };
|
|
if !extend {
|
|
tt.anchor = new;
|
|
}
|
|
}
|
|
})?;
|
|
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
|
}
|
|
|
|
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let a = pos_param(p, "anchor").ok_or_else(|| bad("text.select", "missing `anchor`"))?;
|
|
let c = pos_param(p, "caret").unwrap_or(a);
|
|
s.select(|_, sel| {
|
|
if let Some(t) = sel.text.as_mut() {
|
|
t.anchor = a;
|
|
t.caret = c;
|
|
}
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectAll", "not editing text"))?;
|
|
let end = body_of(st, &t).map(ed::end).unwrap_or((0, 0));
|
|
s.select(|_, sel| {
|
|
if let Some(t) = sel.text.as_mut() {
|
|
t.anchor = (0, 0);
|
|
t.caret = end;
|
|
}
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn select_word(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectWord", "not editing text"))?;
|
|
let at = pos_param(p, "at").unwrap_or(t.caret);
|
|
let body = body_of(st, &t).cloned().unwrap_or_default();
|
|
let (a, b) = ed::word_at(&body, at);
|
|
s.select(|_, sel| {
|
|
if let Some(t) = sel.text.as_mut() {
|
|
t.anchor = a;
|
|
t.caret = b;
|
|
}
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
|
let at = pos_param(p, "at").unwrap_or(t.caret);
|
|
let body = body_of(st, &t);
|
|
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
|
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
|
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
|
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
|
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
|
s.select(|_, sel| {
|
|
if let Some(t) = sel.text.as_mut() {
|
|
t.anchor = (at.0, 0);
|
|
t.caret = end;
|
|
}
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them.
|
|
/// Returns whether any tab was found.
|
|
pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool {
|
|
let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0);
|
|
if tabs == 0 {
|
|
return false;
|
|
}
|
|
if let Some(r) = para.runs.first_mut() {
|
|
r.text = r.text.trim_start_matches('\t').to_string();
|
|
}
|
|
para.level = tabs.min(8) as u8;
|
|
true
|
|
}
|
|
|
|
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string();
|
|
let id = match id_param(p, "id", "text.set")? {
|
|
Some(i) => i,
|
|
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?,
|
|
};
|
|
let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)));
|
|
let inherited = inherited_aligns(s, id);
|
|
edit_shapes(s, &json!({"id": id}), "text.set", |sh| {
|
|
let target = match (&mut sh.kind, cell) {
|
|
(ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text),
|
|
(ShapeKind::Table(_), None) => None,
|
|
_ => Some(sh.text.get_or_insert_with(TextBody::default)),
|
|
};
|
|
let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) };
|
|
let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
|
let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect();
|
|
let mut nb = TextBody::from_text(&text);
|
|
for (i, para) in nb.paragraphs.iter_mut().enumerate() {
|
|
// Leading tabs set the outline level.
|
|
if !apply_leading_tabs(para)
|
|
&& let Some((lv, pp)) = levels.get(i)
|
|
{
|
|
para.level = *lv;
|
|
para.props = pp.clone();
|
|
}
|
|
promote_rtl_if_needed(para, &inherited);
|
|
for r in &mut para.runs {
|
|
r.props = keep.clone();
|
|
}
|
|
}
|
|
body.paragraphs = nb.paragraphs;
|
|
Ok(())
|
|
})?;
|
|
fit_text_box(s, id)?;
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let id = match id_param(p, "id", "text.get")? {
|
|
Some(i) => i,
|
|
None => match &st.selection.text {
|
|
Some(t) => t.shape,
|
|
None => st.selection.shapes.first().copied().ok_or_else(|| bad("text.get", "missing `id`"))?,
|
|
},
|
|
};
|
|
let sh = st.shape(id).ok_or_else(|| bad("text.get", "no such shape"))?;
|
|
let t = sh.text.clone().unwrap_or_default();
|
|
Ok(json!({"text": t.text(), "paragraphs": t.paragraphs.iter().map(|p| json!({"level": p.level, "text": p.text()})).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod select_paragraph_tests {
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
#[test]
|
|
fn select_paragraph_huge_at() {
|
|
let mut s = Session::with_new();
|
|
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
|
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
|
s.execute("text.edit", &json!({})).unwrap();
|
|
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
|
assert!(r.is_ok(), "{r:?}");
|
|
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
|
// Clamped to the last (only) paragraph, never past the end of the body.
|
|
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
|
}
|
|
}
|