//! Review tab: comments, spelling, accessibility; Slide Show requests. use std::sync::Arc; use deckcraft_model::Comment; use serde_json::{Value, json}; use super::*; use crate::{Result, Session, UiRequest}; pub fn specs() -> Vec { vec![ cmd!("comment.add", "New Comment", ["Review", "Comments"], Some("Cmd+Alt+M"), "{text, x?, y?, id?: shape}", has_slide, add), cmd!("comment.reply", "Reply", ["Comments"], None, "{index, text}", has_slide, reply), cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete), cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape, para, start, end, word, lang, suggestions}] (each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling), cmd!( "review.spellingChange", "Change Spelling", ["Review", "Proofing"], None, "{shape, word, to, all?, para?, start?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)", has_doc, spelling_change ), cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary), cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { let i = s.doc()?.selection.slide; start(s, i) }), cmd!( "show.setup", "Set Up Slide Show…", ["Slide Show", "Set Up"], None, "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}", has_doc, setup ), cmd!( "show.customShow", "Custom Slide Show", ["Slide Show", "Start Slide Show"], None, "{name, slides: [index]} | {name, delete: true}", has_doc, custom_show ), ] } fn now() -> String { #[cfg(not(target_arch = "wasm32"))] { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); let (y, m, d) = super::design::civil((secs / 86_400) as i64); let t = secs % 86_400; format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) } #[cfg(target_arch = "wasm32")] { String::new() } } fn add(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string(); let author = s.prefs.author.clone(); let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0)); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?); let initials: String = author.split_whitespace().filter_map(|w| w.chars().next()).collect(); sl.comments.push(Comment { author: author.clone(), initials, text, date: now(), x, y, resolved: false, replies: vec![], shape }); Ok(json!({"index": sl.comments.len() - 1})) }) } fn reply(s: &mut Session, p: &Value) -> Result { let i = usize_param(p, "index").ok_or_else(|| bad("comment.reply", "missing `index`"))?; let text = str_param(p, "text").unwrap_or("").to_string(); let author = s.prefs.author.clone(); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.reply", "no slide"))?); let c = sl.comments.get_mut(i).ok_or_else(|| bad("comment.reply", "no such comment"))?; c.replies.push(Comment { author, text, date: now(), ..Default::default() }); Ok(()) })?; ok() } fn delete(s: &mut Session, p: &Value) -> Result { let all = bool_or(p, "all", false); let i = usize_param(p, "index"); s.edit(|doc, sel| { if all { for sl in &mut doc.slides { Arc::make_mut(sl).comments.clear(); } return Ok(()); } let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.delete", "no slide"))?); match i { Some(i) if i < sl.comments.len() => { sl.comments.remove(i); } None => sl.comments.clear(), _ => return Err(bad("comment.delete", "no such comment")), } Ok(()) })?; ok() } fn resolve(s: &mut Session, p: &Value) -> Result { let i = usize_param(p, "index").ok_or_else(|| bad("comment.resolve", "missing `index`"))?; let v = bool_or(p, "resolved", true); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.resolve", "no slide"))?); sl.comments.get_mut(i).ok_or_else(|| bad("comment.resolve", "no such comment"))?.resolved = v; Ok(()) })?; ok() } fn list(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let i = usize_param(p, "slide").unwrap_or(st.selection.slide); Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default()) } fn accessibility(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let mut out = vec![]; for (i, sl) in st.doc.slides.iter().enumerate() { if sl.title().trim().is_empty() { out.push(json!({"slide": i, "issue": "Missing slide title"})); } deckcraft_model::walk(&sl.shapes, &mut |sh, _| { let needs_alt = matches!( sh.kind, deckcraft_model::ShapeKind::Picture { .. } | deckcraft_model::ShapeKind::Chart(_) | deckcraft_model::ShapeKind::Media(_) | deckcraft_model::ShapeKind::Group { .. } ); if needs_alt && sh.descr.trim().is_empty() && !sh.decorative { out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": "Missing alternative text"})); } }); // Low-contrast text against the slide background. if let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl) { let bg = match deckcraft_model::resolve::background(&ctx, Some(sl)) { (deckcraft_model::Fill::Solid { color }, ph) => Some(ctx.color(&color, ph)), _ => None, }; if let Some(bg) = bg { for sh in &sl.shapes { let Some(t) = &sh.text else { continue }; let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue }; let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default()); let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh); if fill.as_ref().is_some_and(|f| !f.is_none()) { continue; } let fg = deckcraft_model::resolve::text_color(&ctx, &rp); if fg.contrast(bg) < 3.0 { out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))})); } } } } } Ok(Value::Array(out)) } /// Frequent misspellings, used when no dictionary for the language is installed. const COMMON: &[&str] = &[ "teh", "recieve", "seperate", "occured", "untill", "wich", "becuase", "definately", "thier", "alot", "accomodate", "acheive", "adress", "begining", "beleive", "calender", "commited", "enviroment", "goverment", "independant", "neccessary", "occassion", "publically", "reccomend", "tommorow", "wierd", ]; /// The language a check uses: the `lang` parameter, else the first run that says, else US English. fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String { if let Some(l) = str_param(p, "lang") { return l.to_string(); } let mut found = None; for sl in &doc.slides { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { if found.is_none() && let Some(t) = &sh.text { found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()); } }); } found.unwrap_or_else(|| "en-US".into()) } fn spelling(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let default = check_lang(&st.doc, p); let ignore: Vec = p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default(); let mut dicts: std::collections::HashMap>> = Default::default(); let mut out = vec![]; for (i, sl) in st.doc.slides.iter().enumerate() { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { let Some(t) = &sh.text else { return }; for (pi, para) in t.paragraphs.iter().enumerate() { for w in crate::spell::paragraph_words(para, &default) { if ignore.contains(&w.text.to_lowercase()) { continue; } let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); let bad = match &dict { Some(d) => !crate::spell::is_correct(d, &w.text), None => COMMON.contains(&w.text.to_lowercase().as_str()), }; if bad { let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default(); out.push(json!({"slide": i, "shape": sh.id, "para": pi, "start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug})); } } } }); } Ok(Value::Array(out)) } fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result { let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?; crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?; Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())})) } /// Replace whole-word `from` with `to` in `text`; how many were replaced. fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize { let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; let mut n = 0; let mut at = 0; while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { let end = i + from.len(); let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); if before || after { at = end; continue; } text.replace_range(i..end, to); n += 1; at = i + to.len(); if !all { break; } } n } fn spelling_change(s: &mut Session, p: &Value) -> Result { let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string(); let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string(); let all = bool_or(p, "all", false); let shape = p.get("shape").and_then(Value::as_u64); if shape.is_none() && !all { return Err(bad("review.spellingChange", "give `shape`, or `all: true`")); } // One exact occurrence (a squiggled word): paragraph `para`, from character `start`. if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) { let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?); let end = start + word.chars().count(); let slide = { let st = s.doc()?; let found = st.doc.slides.iter().position(|sl| { deckcraft_model::find_shape(&sl.shapes, id) .and_then(|sh| sh.text.as_ref()?.paragraphs.get(para)) .is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::() == word) }); found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))? }; s.edit(|doc, _| { let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?; let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?; if let Some(pp) = sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)) { replace_chars(pp, start, end, &to); } Ok(()) })?; return Ok(json!({"changed": 1})); } // Nothing to change makes no undo step. let mut any = false; let st = s.doc()?; for sl in &st.doc.slides { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id)) && let Some(t) = &sh.text { any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0); } }); } if !any { return Ok(json!({"changed": 0})); } let changed = s.edit(|doc, _| { let mut n = 0; let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()); for sl in &mut doc.slides { let mut hit = false; deckcraft_model::walk(&sl.shapes, &mut |sh, _| { hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str())); }); if !hit || (!all && n > 0) { continue; } walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| { if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) { return; } let Some(t) = sh.text.as_mut() else { return }; for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) { if !all && n > 0 { break; } n += replace_word(&mut r.text, &word, &to, all); } }); } Ok(n) })?; Ok(json!({"changed": changed})) } /// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose /// the rest of the range). fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) { let mut at = 0; let mut placed = false; for r in &mut p.runs { let n = r.char_len(); let (a, b) = (start.max(at), end.min(at + n)); if a < b && r.kind == deckcraft_model::text::RunKind::Text { let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i); let (x, y) = (byte(a - at), byte(b - at)); r.text.replace_range(x..y, if placed { "" } else { to }); placed = true; } at += n; } p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty())); } fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { if depth > 64 { return; } for sh in shapes.iter_mut() { f(sh); if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { walk_text_mut(children, depth + 1, f); } } } fn dictionary_info(s: &mut Session, p: &Value) -> Result { let lang = match s.doc() { Ok(st) => check_lang(&st.doc, p), Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(), }; let dirs = crate::spell::search_dirs(); let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string()); let available = crate::spell::dictionary(&lang).is_some(); Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::>()})) } fn start(s: &mut Session, from: usize) -> Result { s.ui_requests.push(UiRequest::StartShow { from }); Ok(json!({"from": from})) } fn setup(s: &mut Session, p: &Value) -> Result { s.edit(|doc, _| { let sh = &mut doc.show; if let Some(t) = str_param(p, "type") { sh.show_type = t.to_string(); if t == "kiosk" { sh.loop_until_esc = true; } } if let Some(v) = bool_param(p, "loop") { sh.loop_until_esc = v; } if let Some(v) = bool_param(p, "noNarration") { sh.without_narration = v; } if let Some(v) = bool_param(p, "noAnimation") { sh.without_animation = v; } if let Some(v) = bool_param(p, "useTimings") { sh.use_timings = v; } match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) { (Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)), (None, None) if p.get("all").is_some() => sh.range = None, _ => {} } Ok(serde_json::to_value(&*sh).unwrap_or_default()) }) } fn custom_show(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("show.customShow", "missing `name`"))?.to_string(); let delete = bool_or(p, "delete", false); let idx: Vec = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default(); s.edit(|doc, _| { doc.custom_shows.retain(|c| c.name != name); if !delete { let slides = idx.iter().filter_map(|i| doc.slides.get(*i).map(|x| x.id)).collect(); doc.custom_shows.push(deckcraft_model::CustomShow { name: name.clone(), slides }); } Ok(()) })?; ok() }