mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
457 lines
19 KiB
Rust
457 lines
19 KiB
Rust
//! 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<CommandSpec> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
let st = s.doc()?;
|
||
let default = check_lang(&st.doc, p);
|
||
let ignore: Vec<String> =
|
||
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<String, Option<Arc<crate::spell::Dictionary>>> = 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<Value> {
|
||
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<Value> {
|
||
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::<String>() == 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<Value> {
|
||
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::<Vec<_>>()}))
|
||
}
|
||
|
||
fn start(s: &mut Session, from: usize) -> Result<Value> {
|
||
s.ui_requests.push(UiRequest::StartShow { from });
|
||
Ok(json!({"from": from}))
|
||
}
|
||
|
||
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||
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<Value> {
|
||
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<usize> =
|
||
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()
|
||
}
|