mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 14:07:06 -06:00
Merge remote-tracking branch 'upstream/main' into fix-211-markdown-outlines
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
||||
cmd!(
|
||||
"arrange.reorder",
|
||||
"Reorder Objects",
|
||||
["Arrange"],
|
||||
None,
|
||||
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||
has_slide,
|
||||
reorder
|
||||
),
|
||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
// Already in place (front of its group, say): no change and no undo step.
|
||||
let mut trial = s.doc()?.shapes().to_vec();
|
||||
order_tree(&mut trial, &ids, how, 0);
|
||||
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order_tree(list, &ids, how, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Every shape id with its depth, in tree order.
|
||||
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||
let mut out = vec![];
|
||||
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||
order_list(list, ids, how);
|
||||
}
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(children) = sh.children_mut() {
|
||||
order_tree(children, ids, how, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||
let st = s.doc()?;
|
||||
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||
Some(None) => st.shapes(),
|
||||
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||
};
|
||||
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||
return Ok(json!({"index": from}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
||||
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let sh = list.remove(i);
|
||||
list.insert(to.min(list.len()), sh);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
let at = to.min(list.len());
|
||||
list.insert(at, sh);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
edit_shapes(s, p, "arrange.align", |sh| {
|
||||
edit_geometry(s, p, "arrange.align", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let b = x.bounds();
|
||||
let (dx, dy) = match edge.as_str() {
|
||||
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets_pos.push((*id, d));
|
||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||
}
|
||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
||||
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||
if horiz {
|
||||
x.x += d;
|
||||
|
||||
@@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
@@ -83,7 +92,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
s.add_document(DocState::new(p, path, title))
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
s.add_document(st)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -93,7 +105,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -105,7 +118,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
@@ -118,26 +131,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
|
||||
/// includeHidden, textLayer, frame}`.
|
||||
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
|
||||
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
|
||||
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
|
||||
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
|
||||
o.slides = match p.get("slides") {
|
||||
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
|
||||
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
|
||||
_ => None,
|
||||
};
|
||||
o.include_hidden = bool_or(p, "includeHidden", false);
|
||||
o.text_layer = bool_or(p, "textLayer", true);
|
||||
o.frame_slides = bool_or(p, "frame", true);
|
||||
o
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
|
||||
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
|
||||
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
|
||||
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
|
||||
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
@@ -264,7 +310,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": written}))
|
||||
}
|
||||
"pdf" => {
|
||||
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = pdf_bytes(&st.doc, p)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -276,6 +322,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Info: what the Info page shows about the active presentation.
|
||||
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
"format": st.path.as_deref().map(format_for_path),
|
||||
"dirty": st.is_dirty(),
|
||||
"slides": doc.slides.len(),
|
||||
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
|
||||
"sections": doc.sections.len(),
|
||||
"bytes": bytes,
|
||||
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if let Some(i) = s.active_index() {
|
||||
s.close_document(i);
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
color
|
||||
),
|
||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
|
||||
cmd!(
|
||||
"format.case",
|
||||
"Change Case",
|
||||
["Home", "Font"],
|
||||
Some("Shift+F3"),
|
||||
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
|
||||
has_text_or_shapes,
|
||||
case
|
||||
),
|
||||
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
||||
cmd!(
|
||||
"format.spacing",
|
||||
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
if a == b {
|
||||
return ok();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = mode.clone();
|
||||
super::text::edit_text(s, move |body, _| {
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
pub mod table;
|
||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(format::specs());
|
||||
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
||||
})
|
||||
}
|
||||
|
||||
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||
/// locked targets are left out, and when every target is locked nothing changes.
|
||||
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(cmd, "the object is locked"));
|
||||
}
|
||||
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||
}
|
||||
|
||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"selection.select",
|
||||
"Select",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||
has_slide,
|
||||
select
|
||||
),
|
||||
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||
/// object from the list shows it again.
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||
let st = s.doc()?;
|
||||
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(bad("selection.select", "no such shape"));
|
||||
}
|
||||
let kind = valid.first().and_then(in_group);
|
||||
valid.retain(|i| in_group(i) == kind);
|
||||
let keep: Vec<ShapeId> = if toggle || add {
|
||||
let current = st.selection.shapes.clone();
|
||||
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||
return Ok(json!({"selected": current}));
|
||||
}
|
||||
current
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut next = keep;
|
||||
for i in &valid {
|
||||
match next.iter().position(|x| x == i) {
|
||||
Some(k) if toggle => {
|
||||
next.remove(k);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => next.push(*i),
|
||||
}
|
||||
}
|
||||
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||
if reveal.is_empty() {
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
})?;
|
||||
} else {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||
for id in &reveal {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
sh.hidden = false;
|
||||
}
|
||||
}
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||
}
|
||||
|
||||
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn any_hidden(v: &[Shape]) -> bool {
|
||||
let mut found = false;
|
||||
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||
found
|
||||
}
|
||||
if !any_hidden(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
fn rec(v: &mut [Shape], depth: usize) {
|
||||
for sh in v {
|
||||
sh.hidden = false;
|
||||
if depth < 64
|
||||
&& let Some(c) = sh.children_mut()
|
||||
{
|
||||
rec(c, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||
sh.hidden = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||
let x = 20.0 + 110.0 * i as f64;
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
ids.extend([h, g]);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||
fn tree(s: &Session) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(s.doc().unwrap().shapes())
|
||||
}
|
||||
|
||||
/// (hidden, locked)
|
||||
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||
(sh.hidden, sh.locked)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u32> {
|
||||
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn bx(s: &mut Session, id: u32) -> Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
// A child moves among its siblings only and stops at the group's front.
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||
// Steps that changed nothing left no undo step.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||
// Drag A to the front of the slide (an index past the end clamps).
|
||||
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||
// A child is dragged within its group: it never leaves it.
|
||||
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||
// Dropping where it already is changes nothing (no undo step).
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// One undo step per drop.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_show_all_and_hide_all() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||
assert!(flags(&s, b).0);
|
||||
// Hiding a group hides only the group; its children keep their own flags.
|
||||
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||
// Hide All hides the top level only.
|
||||
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||
// Show All shows everything, children included.
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(flags(&s, a).1);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(!flags(&s, a).1, "toggles");
|
||||
// Locking a group locks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||
// A child can be unlocked on its own.
|
||||
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||
// Unlocking the group unlocks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
let before = bx(&mut s, a);
|
||||
for (cmd, p) in [
|
||||
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||
}
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||
let bb = bx(&mut s, b);
|
||||
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||
// Formatting and the name still change.
|
||||
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||
// The arrow keys don't nudge it.
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let _ = s.key("ArrowRight", Mods::default());
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||
let (mut s, ids) = deck();
|
||||
let a = ids[0];
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// A: x 20..120, y 50..130.
|
||||
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_from_the_pane() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b, c]);
|
||||
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c, a, b]);
|
||||
// A child inside a group; children across nesting levels go together.
|
||||
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d]);
|
||||
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// Picking makes no undo step...
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// ...unless it shows a hidden object again.
|
||||
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, c).0);
|
||||
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_the_pane_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let e = ids[4];
|
||||
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||
undo(&mut s);
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||
}
|
||||
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||||
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
|
||||
cmd!(
|
||||
"shape.lock",
|
||||
"Lock",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||||
has_selection,
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_shapes(s, p, "shape.move", |sh| {
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||||
let lock = bool_or(p, "lockAspect", false);
|
||||
edit_shapes(s, p, "shape.resize", |sh| {
|
||||
edit_geometry(s, p, "shape.resize", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "shape.setBounds", |sh| {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_shapes(s, p, "shape.rotate", |sh| {
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_shapes(s, p, "shape.flip", |sh| {
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_shapes(s, p, "shape.adjust", |sh| {
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_shapes(s, p, "shape.change", |sh| {
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||||
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
sh.locked = v.unwrap_or(!sh.locked);
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||||
sh.locked = to;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for c in children {
|
||||
set_locked(c, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||||
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
edit_shapes(s, p, "picture.crop", |sh| {
|
||||
edit_geometry(s, p, "picture.crop", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
for (i, v) in c.iter().enumerate() {
|
||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||
|
||||
+213
-15
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||
// The input method's composition: shown in place, an undo step only once committed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||
enabled: has_text,
|
||||
run: compose,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(
|
||||
noundo "text.commit",
|
||||
"Commit Composition",
|
||||
[],
|
||||
None,
|
||||
"{text} — the input method's result replaces its composition, as one undo step",
|
||||
has_text,
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
Some("Backspace"),
|
||||
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
||||
))
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||
/// table cells, which aren't laid out on the slide here.
|
||||
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let mut body = sh.text.clone().unwrap_or_default();
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
// After End on a wrapped line the caret is drawn at that line's end.
|
||||
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -184,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -340,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||
fn ime_text(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||
let Some(t) = &st.selection.text else { return false };
|
||||
let Some(body) = body_of(st, t) else { return false };
|
||||
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||
}
|
||||
|
||||
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||
/// goes back to how it was before the composition began.
|
||||
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||
let n = text.chars().count();
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x < y).then_some((x.min(n), y.min(n)))
|
||||
});
|
||||
let current = s.doc()?.composition.clone();
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
match current {
|
||||
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||
let (start, props) = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||
let c = ed::insert(body, at, &text, props.clone());
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
(at, props)
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
Some(c) if text.is_empty() => {
|
||||
let st = s.doc_mut()?;
|
||||
st.doc = c.before;
|
||||
st.selection = c.before_selection;
|
||||
st.composition = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"composing": false}))
|
||||
}
|
||||
Some(c) => {
|
||||
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||
edit_text(s, |body, t| {
|
||||
ed::delete(body, start, (start.0, start.1 + len));
|
||||
let e = ed::insert(body, start, &text, props);
|
||||
t.anchor = e;
|
||||
t.caret = e;
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||
compose(s, &json!({"text": text}))?;
|
||||
}
|
||||
s.end_composition();
|
||||
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||
}
|
||||
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||
let mut out = vec![];
|
||||
let mut add = |a: usize, b: usize, thick: bool| {
|
||||
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||
}
|
||||
};
|
||||
add(0, c.len, false);
|
||||
if let Some((a, b)) = c.active {
|
||||
add(a, b.min(c.len), true);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -349,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if a != b {
|
||||
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),
|
||||
"wordBackward" => ed::word_move(body, a, false),
|
||||
"wordForward" => ed::word_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
@@ -382,7 +550,16 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
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()?;
|
||||
@@ -391,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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),
|
||||
"wordLeft" => ed::word_move(&body, caret, false),
|
||||
"wordRight" => ed::word_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(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
||||
let r = match to.as_str() {
|
||||
"up" => l.vertical(lp(caret), false, x),
|
||||
"down" => l.vertical(lp(caret), true, x),
|
||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
||||
_ => l.line_bounds(lp(caret)).1,
|
||||
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() {
|
||||
@@ -427,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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]}))
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -479,11 +674,14 @@ 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 n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
let body = body_of(st, &t);
|
||||
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 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = (at.0, n);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
|
||||
Reference in New Issue
Block a user