mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
Found while testing crates/engine/src/cmd/review.rs:140-155 (delete) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
818 lines
36 KiB
Rust
818 lines
36 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, Target, 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!(
|
||
noundo "comment.next",
|
||
"Next Comment",
|
||
["Review", "Comments"],
|
||
None,
|
||
"{slide?, index?} → {slide, index, text}",
|
||
has_slide,
|
||
|s, p| neighbour(s, p, true)
|
||
),
|
||
cmd!(
|
||
noundo "comment.prev",
|
||
"Previous Comment",
|
||
["Review", "Comments"],
|
||
None,
|
||
"{slide?, index?} → {slide, index, text}",
|
||
has_slide,
|
||
|s, p| neighbour(s, p, false)
|
||
),
|
||
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?, row?, col?, notes?, para, start, end, word, lang, suggestions}] (shapes, table cells and notes; repeated words as {repeated, deleteFrom}; runs marked noProof skipped; 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?, row?, col?} → {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!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||
cmd!(
|
||
"review.replaceWord",
|
||
"Insert Synonym",
|
||
[],
|
||
None,
|
||
"{with}: replace the selected word (or the word at the caret) while editing text",
|
||
has_text,
|
||
insert_synonym
|
||
),
|
||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||
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?, showMediaControls?, from?, to?, custom?: name}",
|
||
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
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Seconds since the Unix epoch: the system clock, or the browser's on wasm (which has no `SystemTime`).
|
||
fn now_secs() -> u64 {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
(js_sys::Date::now() / 1000.0).max(0.0) as u64
|
||
}
|
||
}
|
||
|
||
/// A UTC timestamp as saved on comments: `2026-10-10T13:10:35Z`.
|
||
fn iso_utc(secs: u64) -> String {
|
||
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)
|
||
}
|
||
|
||
fn now() -> String {
|
||
iso_utc(now_secs())
|
||
}
|
||
|
||
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", "comment.add")?.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 = match p.get("index") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(
|
||
v.as_u64()
|
||
.and_then(|v| usize::try_from(v).ok())
|
||
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
|
||
),
|
||
};
|
||
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())
|
||
}
|
||
|
||
/// Flat `(slide, comment index)` list in deck order.
|
||
fn comment_threads(doc: &deckcraft_model::Presentation) -> Vec<(usize, usize)> {
|
||
let mut out = Vec::new();
|
||
for (si, sl) in doc.slides.iter().enumerate() {
|
||
for ci in 0..sl.comments.len() {
|
||
out.push((si, ci));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Step to the next or previous comment. With `{slide, index}` matching a thread, move one step
|
||
/// and stop at the ends. Without `index`, next is the first thread on or after `slide` (the
|
||
/// current slide when omitted) and previous is the last thread strictly before it.
|
||
fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
|
||
let cmd = if forward { "comment.next" } else { "comment.prev" };
|
||
let (threads, slide, index) = {
|
||
let st = s.doc()?;
|
||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
|
||
};
|
||
if threads.is_empty() {
|
||
return Err(bad(cmd, "no comments"));
|
||
}
|
||
let pos = index.and_then(|i| threads.iter().position(|&(sl, c)| sl == slide && c == i));
|
||
let target = match (pos, forward) {
|
||
(Some(i), true) => threads.get(i + 1).copied(),
|
||
(Some(i), false) => i.checked_sub(1).and_then(|j| threads.get(j).copied()),
|
||
(None, true) => threads.iter().copied().find(|(sl, _)| *sl >= slide),
|
||
(None, false) => threads.iter().copied().rev().find(|(sl, _)| *sl < slide),
|
||
};
|
||
let Some((slide, index)) = target else {
|
||
return Err(bad(cmd, "no more comments"));
|
||
};
|
||
s.select(|doc, sel| {
|
||
sel.slide = slide;
|
||
sel.slides = doc.slides.get(slide).map(|x| vec![x.id]).unwrap_or_default();
|
||
sel.shapes.clear();
|
||
sel.text = None;
|
||
sel.cells = None;
|
||
sel.target = Target::Slides;
|
||
})?;
|
||
let text = s.doc()?.doc.slides.get(slide).and_then(|sl| sl.comments.get(index)).map(|c| c.text.clone()).unwrap_or_default();
|
||
Ok(json!({"slide": slide, "index": index, "text": text}))
|
||
}
|
||
|
||
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![];
|
||
// Each misspelt word of `para`, reported with `at` (where the paragraph is) merged in.
|
||
let mut check = |para: &deckcraft_model::Paragraph, at: Value, out: &mut Vec<Value>| {
|
||
for w in crate::spell::repeated(para, &default) {
|
||
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "repeated": true, "deleteFrom": w.repeated, "suggestions": []});
|
||
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
|
||
o.extend(a.clone());
|
||
}
|
||
out.push(v);
|
||
}
|
||
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||
for w in crate::spell::paragraph_words(para, &default) {
|
||
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
|
||
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();
|
||
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug});
|
||
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
|
||
o.extend(a.clone());
|
||
}
|
||
out.push(v);
|
||
}
|
||
}
|
||
};
|
||
for (i, sl) in st.doc.slides.iter().enumerate() {
|
||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||
if let Some(t) = &sh.text {
|
||
for (pi, para) in t.paragraphs.iter().enumerate() {
|
||
check(para, json!({"slide": i, "shape": sh.id, "para": pi}), &mut out);
|
||
}
|
||
}
|
||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||
for (r, row) in tb.rows.iter().enumerate() {
|
||
for (c, cell) in row.cells.iter().enumerate().filter(|(_, c)| !c.is_covered()) {
|
||
for (pi, para) in cell.text.paragraphs.iter().enumerate() {
|
||
check(para, json!({"slide": i, "shape": sh.id, "row": r, "col": c, "para": pi}), &mut out);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
// Speaker notes, a paragraph per line.
|
||
for (pi, para) in sl.notes.paragraphs.iter().enumerate() {
|
||
check(para, json!({"slide": i, "notes": true, "para": pi}), &mut out);
|
||
}
|
||
}
|
||
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 cell = usize_param(p, "row").zip(usize_param(p, "col"));
|
||
let slide = {
|
||
let st = s.doc()?;
|
||
let found = st.doc.slides.iter().position(|sl| {
|
||
deckcraft_model::find_shape(&sl.shapes, id)
|
||
.and_then(|sh| match (cell, &sh.kind) {
|
||
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell(r, c)?.text.paragraphs.get(para),
|
||
(Some(_), _) => None,
|
||
(None, _) => 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"))?;
|
||
let pp = match (cell, &mut sh.kind) {
|
||
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell_mut(r, c).and_then(|cl| cl.text.paragraphs.get_mut(para)),
|
||
(Some(_), _) => None,
|
||
(None, _) => sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)),
|
||
};
|
||
if let Some(pp) = pp {
|
||
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;
|
||
}
|
||
if let Some(v) = bool_param(p, "showMediaControls") {
|
||
sh.show_media_controls = 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,
|
||
_ => {}
|
||
}
|
||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||
sh.range = None;
|
||
sh.custom_show = None;
|
||
}
|
||
if p.get("custom").is_some() {
|
||
let name = p.get("custom").and_then(Value::as_str).unwrap_or("");
|
||
sh.custom_show = if name.is_empty() { None } else { Some(name.to_string()) };
|
||
if sh.custom_show.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()
|
||
}
|
||
|
||
/// The selected text when it lies in one paragraph, else the word at the caret.
|
||
fn word_under_caret(s: &Session) -> Option<String> {
|
||
let st = s.doc().ok()?;
|
||
let t = st.selection.text.clone()?;
|
||
let body = super::text::body_of(st, &t)?;
|
||
let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) };
|
||
if a.0 != b.0 {
|
||
return None;
|
||
}
|
||
let text = body.paragraphs.get(a.0)?.text();
|
||
let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect();
|
||
let w = w.trim().to_string();
|
||
(!w.is_empty()).then_some(w)
|
||
}
|
||
|
||
fn thesaurus(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default();
|
||
let senses = crate::thesaurus::lookup(&word);
|
||
Ok(json!({"word": word, "senses": senses}))
|
||
}
|
||
|
||
fn insert_synonym(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string();
|
||
if with.chars().count() > 200 {
|
||
return Err(bad("review.replaceWord", "too long"));
|
||
}
|
||
let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret);
|
||
if collapsed {
|
||
s.execute("text.selectWord", &json!({}))?;
|
||
}
|
||
s.execute("text.insert", &json!({"text": with}))
|
||
}
|
||
|
||
fn comparing(s: &Session) -> std::result::Result<(), String> {
|
||
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
|
||
}
|
||
|
||
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||
if let Some(data) = str_param(p, "data") {
|
||
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
|
||
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(path) = str_param(p, "path") {
|
||
let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?;
|
||
return Ok((path.to_string(), bytes));
|
||
}
|
||
Err(bad("review.compare", "give `path` or `data`"))
|
||
}
|
||
|
||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||
}
|
||
|
||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (name, bytes) = revised_bytes(p)?;
|
||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||
let st = s.doc_mut()?;
|
||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||
revisions_json(s.doc()?)
|
||
}
|
||
|
||
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||
revisions_json(s.doc()?)
|
||
}
|
||
|
||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||
let all = bool_param(p, "all").unwrap_or(false);
|
||
let index = usize_param(p, "index");
|
||
if !all && index.is_none() {
|
||
return Err(bad("review.accept", "give `index` or `all`"));
|
||
}
|
||
s.edit(|doc, sel| {
|
||
let mut accepted = 0usize;
|
||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||
for _ in 0..limit {
|
||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||
let c = if all {
|
||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||
} else {
|
||
changes.into_iter().nth(index.unwrap_or(0))
|
||
};
|
||
let Some(c) = c else { break };
|
||
let Some(at) = crate::compare::accept(doc, &rev, &c)? else { break };
|
||
accepted += 1;
|
||
sel.slide = at;
|
||
sel.shapes.clear();
|
||
sel.text = None;
|
||
if !all {
|
||
break;
|
||
}
|
||
}
|
||
if accepted == 0 {
|
||
return Err(bad("review.accept", "no such revision"));
|
||
}
|
||
Ok(json!({"accepted": accepted}))
|
||
})
|
||
}
|
||
|
||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||
s.doc_mut()?.compare = None;
|
||
ok()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn previous_and_next_comment_walk_the_deck() {
|
||
let mut s = Session::with_new();
|
||
let err = s.execute("comment.next", &json!({})).unwrap_err();
|
||
assert!(err.to_string().contains("no comments"), "{err}");
|
||
|
||
s.execute("comment.add", &json!({"text": "on first"})).unwrap();
|
||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||
s.execute("slide.new", &json!({})).unwrap();
|
||
s.execute("comment.add", &json!({"text": "on second a"})).unwrap();
|
||
s.execute("comment.add", &json!({"text": "on second b"})).unwrap();
|
||
s.execute("slide.new", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().selection.slide, 2);
|
||
|
||
let err = s.execute("comment.next", &json!({})).unwrap_err();
|
||
assert!(err.to_string().contains("no more comments"), "{err}");
|
||
|
||
let undo_before = s.doc().unwrap().history.undo.len();
|
||
let back = s.execute("comment.prev", &json!({})).unwrap();
|
||
assert_eq!(back["slide"], 1);
|
||
assert_eq!(back["index"], 1);
|
||
assert_eq!(back["text"], "on second b");
|
||
assert_eq!(s.doc().unwrap().selection.slide, 1);
|
||
assert!(s.doc().unwrap().selection.shapes.is_empty());
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||
|
||
let prev = s.execute("comment.prev", &json!({"slide": 1, "index": 1})).unwrap();
|
||
assert_eq!(prev["index"], 0);
|
||
assert_eq!(prev["text"], "on second a");
|
||
let first = s.execute("comment.prev", &json!({"slide": prev["slide"], "index": prev["index"]})).unwrap();
|
||
assert_eq!(first["slide"], 0);
|
||
assert_eq!(first["text"], "on first");
|
||
let err = s.execute("comment.prev", &json!({"slide": 0, "index": 0})).unwrap_err();
|
||
assert!(err.to_string().contains("no more comments"), "{err}");
|
||
|
||
let next = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
|
||
assert_eq!(next["slide"], 1);
|
||
assert_eq!(next["text"], "on second a");
|
||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||
let here = s.execute("comment.next", &json!({})).unwrap();
|
||
assert_eq!(here["text"], "on first");
|
||
}
|
||
|
||
#[test]
|
||
fn timestamps_are_iso_utc() {
|
||
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
|
||
assert_eq!(iso_utc(1_791_637_835), "2026-10-10T13:10:35Z");
|
||
}
|
||
|
||
#[test]
|
||
fn the_clock_is_not_empty() {
|
||
// On wasm this used to return an empty string, so browser comments were saved undated.
|
||
assert!(now().starts_with("20"), "{}", now());
|
||
}
|
||
|
||
#[test]
|
||
fn show_media_controls_follow_setup() {
|
||
let mut s = Session::with_new();
|
||
assert!(s.doc().expect("doc").doc.show.show_media_controls);
|
||
let off = s.execute("show.setup", &json!({"showMediaControls": false})).expect("off");
|
||
assert_eq!(off["showMediaControls"], false);
|
||
assert_eq!(off["useTimings"], true, "other setup flags stay");
|
||
let timings = s.execute("show.setup", &json!({"useTimings": false})).expect("timings");
|
||
assert_eq!(timings["showMediaControls"], false);
|
||
assert_eq!(timings["useTimings"], false);
|
||
s.execute("show.setup", &json!({"showMediaControls": true})).expect("on");
|
||
assert!(s.doc().expect("doc").doc.show.show_media_controls);
|
||
}
|
||
|
||
#[test]
|
||
fn setup_remembers_a_custom_show() {
|
||
let mut s = Session::with_new();
|
||
s.execute("slide.new", &json!({})).unwrap();
|
||
s.execute("slide.new", &json!({})).unwrap();
|
||
s.execute("show.customShow", &json!({"name": "Short", "slides": [0, 1]})).unwrap();
|
||
let v = s.execute("show.setup", &json!({"custom": "Short"})).unwrap();
|
||
assert_eq!(v.get("customShow").and_then(Value::as_str), Some("Short"));
|
||
assert!(s.active().unwrap().doc.show.range.is_none());
|
||
s.execute("show.setup", &json!({"custom": ""})).unwrap();
|
||
assert!(s.active().unwrap().doc.show.custom_show.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
|
||
let mut s = Session::with_new();
|
||
s.execute("comment.add", &json!({"text": "a"})).unwrap();
|
||
s.execute("comment.add", &json!({"text": "b"})).unwrap();
|
||
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
|
||
let r = s.execute("comment.delete", &json!({"index": bad_index}));
|
||
assert!(r.is_err(), "index {bad_index} gave {r:?}");
|
||
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
|
||
}
|
||
s.execute("comment.delete", &json!({})).unwrap();
|
||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||
}
|
||
}
|