mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 20:16:50 -06:00
Merge the startup, clipboard, animation-preview and UI PRs integrated on land-r5 (#644 #652 #655 #702 #571 #539 #674 #648 #697 #479 #369 #368)
# Conflicts: # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -13,9 +14,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
|
||||
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
["Home", "Clipboard"],
|
||||
Some("Cmd+V"),
|
||||
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special",
|
||||
@@ -134,15 +143,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A caret copies nothing; the previous clipboard must not leak into this answer.
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
|
||||
fn copied_html(s: &Session) -> String {
|
||||
if let Some(body) = &s.clipboard.text {
|
||||
return richclip::body_to_html(body);
|
||||
}
|
||||
let mut paragraphs = Vec::new();
|
||||
for sh in &s.clipboard.shapes {
|
||||
if let Some(t) = &sh.text {
|
||||
paragraphs.extend(t.paragraphs.clone());
|
||||
}
|
||||
}
|
||||
if paragraphs.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
delete(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
|
||||
@@ -249,9 +284,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// External plain text (from the system clipboard) wins when given and differs from ours.
|
||||
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
|
||||
if let Some(t) = str_param(p, "text")
|
||||
&& t != s.clipboard.plain
|
||||
{
|
||||
if let Some(html) = str_param(p, "html")
|
||||
&& let Some(body) = richclip::html_to_body(html)
|
||||
&& richclip::same_words(&body.text(), t)
|
||||
{
|
||||
return paste_external_body(s, &body);
|
||||
}
|
||||
return paste_plain(s, t);
|
||||
}
|
||||
let editing = s.doc()?.selection.text.is_some();
|
||||
@@ -328,6 +370,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::paste_body(s, body);
|
||||
}
|
||||
let plain = body.text();
|
||||
let st = s.doc()?;
|
||||
let size = st.doc.slide_size;
|
||||
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
|
||||
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
|
||||
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
|
||||
let body = body.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
|
||||
*t = body.clone();
|
||||
t.body.wrap = wrap;
|
||||
t.body.autofit = autofit;
|
||||
}
|
||||
sel.shapes = vec![ShapeId(id)];
|
||||
sel.text = None;
|
||||
Ok(json!({"id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::insert_text(s, text, None);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod picture_style;
|
||||
pub mod points;
|
||||
pub mod recolor;
|
||||
pub mod review;
|
||||
mod richclip;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Rich text on the system clipboard, as HTML.
|
||||
//!
|
||||
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
const MAX_PARAS: usize = 2_000;
|
||||
const MAX_RUNS: usize = 10_000;
|
||||
|
||||
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
if props.bold == Some(true) {
|
||||
open.push_str("<b>");
|
||||
close.insert_str(0, "</b>");
|
||||
}
|
||||
if props.italic == Some(true) {
|
||||
open.push_str("<i>");
|
||||
close.insert_str(0, "</i>");
|
||||
}
|
||||
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
|
||||
open.push_str("<u>");
|
||||
close.insert_str(0, "</u>");
|
||||
}
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
/// says bold, italic or underline. Other tags are ignored. The result is capped.
|
||||
pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
if html.len() > MAX_HTML {
|
||||
return None;
|
||||
}
|
||||
let bytes = html.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
|
||||
if bytes[i] == b'<' {
|
||||
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
|
||||
let raw = &html[i + 1..end];
|
||||
i = (end + 1).min(bytes.len());
|
||||
let token = raw.trim();
|
||||
if token.starts_with('!') || token.starts_with('?') {
|
||||
continue;
|
||||
}
|
||||
let closing = token.starts_with('/');
|
||||
let name_src = token.trim_start_matches('/').trim();
|
||||
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
|
||||
let name = name_src[..name_end].to_ascii_lowercase();
|
||||
if name == "script" || name == "style" || name == "head" {
|
||||
if closing {
|
||||
skip = (skip - 1).max(0);
|
||||
} else if !token.ends_with('/') {
|
||||
skip += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
}
|
||||
let (db, di, du) = tag_style(&name, name_src);
|
||||
if db + di + du > 0 {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let text = decode_text(&html[i..end]);
|
||||
i = end;
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
|
||||
}
|
||||
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
|
||||
paragraphs.pop();
|
||||
}
|
||||
if paragraphs.iter().all(Paragraph::is_empty) {
|
||||
return None;
|
||||
}
|
||||
for p in &mut paragraphs {
|
||||
p.normalize();
|
||||
}
|
||||
Some(TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn tag_kind(name: &str) -> &'static str {
|
||||
match name {
|
||||
"b" | "strong" => "b",
|
||||
"i" | "em" => "i",
|
||||
"u" => "u",
|
||||
"span" | "font" => "span",
|
||||
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_break(name: &str) -> bool {
|
||||
name == "br" || name == "hr"
|
||||
}
|
||||
|
||||
fn is_block(name: &str) -> bool {
|
||||
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
|
||||
}
|
||||
|
||||
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
let mut bold = i32::from(name == "b" || name == "strong");
|
||||
let mut italic = i32::from(name == "i" || name == "em");
|
||||
let mut underline = i32::from(name == "u");
|
||||
if let Some(style) = attr(src, "style") {
|
||||
let s = style.to_ascii_lowercase();
|
||||
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
|
||||
bold = 1;
|
||||
}
|
||||
if s.contains("font-style:italic") || s.contains("font-style: italic") {
|
||||
italic = 1;
|
||||
}
|
||||
if s.contains("text-decoration:underline")
|
||||
|| s.contains("text-decoration: underline")
|
||||
|| s.contains("text-decoration-line:underline")
|
||||
|| s.contains("text-decoration-line: underline")
|
||||
{
|
||||
underline = 1;
|
||||
}
|
||||
}
|
||||
(bold, italic, underline)
|
||||
}
|
||||
|
||||
fn weight_bold(style: &str) -> bool {
|
||||
let Some(at) = style.find("font-weight") else { return false };
|
||||
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let at = lower.find(key)?;
|
||||
let after = tag.get(at + key.len()..)?.trim_start();
|
||||
let after = after.strip_prefix('=')?.trim_start();
|
||||
let quote = after.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = after.get(quote.len_utf8()..)?;
|
||||
let end = rest.find(quote)?;
|
||||
rest.get(..end)
|
||||
}
|
||||
|
||||
fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
if paragraphs.len() >= MAX_PARAS {
|
||||
return;
|
||||
}
|
||||
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
|
||||
paragraphs.push(Paragraph::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
|
||||
if *runs >= MAX_RUNS {
|
||||
return;
|
||||
}
|
||||
let Some(p) = paragraphs.last_mut() else { return };
|
||||
let mut props = RunProps::default();
|
||||
if bold {
|
||||
props.bold = Some(true);
|
||||
}
|
||||
if italic {
|
||||
props.italic = Some(true);
|
||||
}
|
||||
if underline {
|
||||
props.underline = Some("sng".into());
|
||||
}
|
||||
p.runs.push(Run::with(text, props));
|
||||
*runs += 1;
|
||||
}
|
||||
|
||||
fn decode_text(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = raw;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
|
||||
rest = &rest[at..];
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
match entity {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
"nbsp" => out.push(' '),
|
||||
_ => {
|
||||
let ch = entity.strip_prefix('#').and_then(numeric_char);
|
||||
if let Some(c) = ch {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
/// Do the HTML's words and the paste's plain text say the same thing?
|
||||
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
|
||||
let a = fold(html_plain);
|
||||
let b = fold(pasted);
|
||||
a == b
|
||||
}
|
||||
|
||||
fn fold(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
body.paragraphs[0].runs = vec![
|
||||
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
|
||||
Run::new(" "),
|
||||
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
|
||||
];
|
||||
let html = body_to_html(&body);
|
||||
assert!(html.contains("<b>Hi</b>"), "{html}");
|
||||
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
|
||||
let back = html_to_body(&html).unwrap();
|
||||
assert_eq!(back.text(), "Hi there");
|
||||
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let last = back.paragraphs[0].runs.last().unwrap();
|
||||
assert_eq!(last.props.italic, Some(true));
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_style_and_escapes() {
|
||||
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A & B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "A & B\nC");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
|
||||
assert!(same_words(&body.text(), "A & B\nC"));
|
||||
assert!(!same_words(&body.text(), "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_fragment_is_refused() {
|
||||
let html = "x".repeat(MAX_HTML + 1);
|
||||
assert!(html_to_body(&html).is_none());
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_slide,
|
||||
freeform
|
||||
),
|
||||
cmd!(
|
||||
"draw.inkToShape",
|
||||
"Ink to Shape",
|
||||
["Draw", "Convert"],
|
||||
None,
|
||||
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
|
||||
has_slide,
|
||||
ink_to_shape
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||||
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
|
||||
cmd!(
|
||||
"shape.rotate",
|
||||
"Rotation",
|
||||
["Shape Format", "Arrange", "Rotate"],
|
||||
None,
|
||||
"{deg?: absolute, by?: relative, ids?, gesture?: move|end}",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
cmd!(
|
||||
"shape.sizePosition",
|
||||
"Size and Position…",
|
||||
@@ -336,6 +353,15 @@ fn size_position(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A trackpad twist sends many small steps. `gesture: move` keeps them as one undo;
|
||||
// `gesture: end` commits that step and does not rotate again.
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("end") {
|
||||
s.commit_named("Rotation");
|
||||
return Ok(json!({}));
|
||||
}
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("move") {
|
||||
s.begin("Rotation");
|
||||
}
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
@@ -1246,3 +1272,127 @@ mod size_position_tests {
|
||||
assert!(err.to_string().contains("nothing selected"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
|
||||
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
|
||||
fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
use crate::inkshape::{Recognized, recognize};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let shapes = s.doc()?.shapes();
|
||||
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
|
||||
return Ok(json!({"shapes": [], "kept": 0}));
|
||||
};
|
||||
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
|
||||
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
|
||||
let found: Vec<Option<Recognized>> = strokes
|
||||
.iter()
|
||||
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
|
||||
.collect();
|
||||
if found.iter().all(Option::is_none) {
|
||||
return Ok(json!({"shapes": [], "kept": strokes.len()}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mut new = vec![];
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
|
||||
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
|
||||
let all = std::mem::take(strokes);
|
||||
let mut kept = vec![];
|
||||
let mut made = vec![];
|
||||
for (st, r) in all.into_iter().zip(found.iter()) {
|
||||
let Some(r) = r else {
|
||||
kept.push(st);
|
||||
continue;
|
||||
};
|
||||
let (preset, xfrm) = match *r {
|
||||
Recognized::Line { x0, y0, x1, y1 } => {
|
||||
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
|
||||
}
|
||||
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
|
||||
};
|
||||
made.push((preset, xfrm, st.color, st.width));
|
||||
}
|
||||
let remaining = kept.len();
|
||||
*strokes = kept;
|
||||
if remaining == 0 {
|
||||
list.remove(pos);
|
||||
}
|
||||
for (preset, xfrm, color, width) in made {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
match preset {
|
||||
"line" => "Straight Connector",
|
||||
"rect" => "Rectangle",
|
||||
"ellipse" => "Oval",
|
||||
_ => "Isosceles Triangle",
|
||||
},
|
||||
id.0
|
||||
);
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
list.push(Shape {
|
||||
id,
|
||||
name,
|
||||
xfrm: Some(xfrm),
|
||||
geom: Geom::preset(preset),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
|
||||
kind: ShapeKind::Shape,
|
||||
..Default::default()
|
||||
});
|
||||
new.push(id);
|
||||
}
|
||||
sel.shapes = new.clone();
|
||||
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
st.shape(id).unwrap().xfrm.unwrap().rot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twist_is_one_undo_and_wraps_past_a_full_turn() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap();
|
||||
let id = id.get("id").and_then(Value::as_u64).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01);
|
||||
|
||||
s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s));
|
||||
s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||||
assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation");
|
||||
assert!(s.active().unwrap().interaction.is_none());
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ending_a_twist_does_not_commit_a_drag() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle.
|
||||
//!
|
||||
//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends
|
||||
//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1,
|
||||
//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the
|
||||
//! centre.
|
||||
|
||||
/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Recognized {
|
||||
/// From `(x0, y0)` to `(x1, y1)`.
|
||||
Line {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
},
|
||||
Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
Ellipse {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
/// Apex at the top (`flip_v` false) or at the bottom.
|
||||
Triangle {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
flip_v: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Strokes smaller than this (points) stay ink.
|
||||
const MIN_SIZE: f64 = 6.0;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
pub fn recognize(points: &[(f64, f64)]) -> Option<Recognized> {
|
||||
let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect();
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for (x, y) in &pts {
|
||||
x0 = x0.min(*x);
|
||||
y0 = y0.min(*y);
|
||||
x1 = x1.max(*x);
|
||||
y1 = y1.max(*y);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let size = w.max(h);
|
||||
if !size.is_finite() || size < MIN_SIZE {
|
||||
return None;
|
||||
}
|
||||
let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum();
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
let gap = (last.0 - first.0).hypot(last.1 - first.1);
|
||||
if length <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Open stroke: a line if nearly straight, otherwise not a shape.
|
||||
if gap > 0.25 * size {
|
||||
return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 });
|
||||
}
|
||||
// Closed but flat: a line drawn back and forth isn't a shape.
|
||||
if w.min(h) < 0.15 * size {
|
||||
return None;
|
||||
}
|
||||
// Covered area (shoelace, the gap closed) over the bounding box.
|
||||
let mut a2 = 0.0;
|
||||
for k in 0..pts.len() {
|
||||
let (p, q) = (pts[k], pts[(k + 1) % pts.len()]);
|
||||
a2 += p.0 * q.1 - q.0 * p.1;
|
||||
}
|
||||
let cover = (a2.abs() / 2.0) / (w * h);
|
||||
// Ellipse fit: distance from the centre in box-normalised units should stay near 1.
|
||||
let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0);
|
||||
let radial: Vec<f64> = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect();
|
||||
let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::<f64>() / radial.len() as f64;
|
||||
if cover > 0.86 {
|
||||
Some(Recognized::Rect { x: x0, y: y0, w, h })
|
||||
} else if cover > 0.66 && spread < 0.12 {
|
||||
Some(Recognized::Ellipse { x: x0, y: y0, w, h })
|
||||
} else if (0.35..0.66).contains(&cover) {
|
||||
// Apex: the side of the box with fewer points near it is where the triangle narrows.
|
||||
let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count();
|
||||
let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count();
|
||||
Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> {
|
||||
let mut v = vec![];
|
||||
for k in 0..corners.len() {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
|
||||
for i in 0..per_side {
|
||||
let t = i as f64 / per_side as f64;
|
||||
// A little hand wobble.
|
||||
let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8;
|
||||
v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob));
|
||||
}
|
||||
}
|
||||
v.push((corners[0].0 + 2.0, corners[0].1 + 1.5));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_ellipses_triangles_and_lines() {
|
||||
let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap();
|
||||
assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}");
|
||||
let ell: Vec<(f64, f64)> = (0..=80)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 80.0 * std::f64::consts::TAU;
|
||||
(200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell));
|
||||
let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}");
|
||||
let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}");
|
||||
let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect();
|
||||
assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribbles_dots_and_garbage_stay_ink() {
|
||||
assert_eq!(recognize(&[]), None);
|
||||
assert_eq!(recognize(&[(1.0, 1.0)]), None);
|
||||
assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small");
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect();
|
||||
assert_eq!(recognize(&zig), None, "an open zigzag is no line");
|
||||
assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None);
|
||||
let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect();
|
||||
assert_eq!(recognize(&there_and_back), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_to_shape_command_converts_and_undoes() {
|
||||
use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
let mut s = crate::Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30);
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect();
|
||||
let mk = |v: &[(f64, f64)]| InkStroke {
|
||||
points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(),
|
||||
color: deckcraft_color::Rgba::rgb(200, 0, 0),
|
||||
width: 3.0,
|
||||
highlighter: false,
|
||||
};
|
||||
let strokes = vec![mk(&rect), mk(&zig)];
|
||||
s.edit(|doc, sel| {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
crate::shapes_mut(doc, sel).unwrap().push(Shape {
|
||||
id,
|
||||
name: "Ink".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
kind: ShapeKind::Ink { strokes },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(serde_json::Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
let n0 = s.doc().unwrap().shapes().len();
|
||||
let r = s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
assert_eq!(r["kept"], 1);
|
||||
let sh = s.doc().unwrap().shapes();
|
||||
assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added");
|
||||
let made = sh.last().unwrap();
|
||||
assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"));
|
||||
assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), n0);
|
||||
// Nothing recognisable: a no-op, not an error. Empty slide too.
|
||||
s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod drawtable;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod inkshape;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
@@ -353,6 +354,29 @@ impl Default for Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// The preferences with every value in a range the editor can use: `prefs.json` is a file
|
||||
/// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo
|
||||
/// limit) must not break editing or exhaust memory. Out-of-range numbers take their
|
||||
/// defaults or the nearest usable value.
|
||||
pub fn sanitized(mut self) -> Prefs {
|
||||
let d = Prefs::default();
|
||||
let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default };
|
||||
self.grid_spacing =
|
||||
if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing };
|
||||
self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset);
|
||||
self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT);
|
||||
self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes);
|
||||
if self.author.chars().count() > 256 {
|
||||
self.author = self.author.chars().take(256).collect();
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The most undo steps [`Prefs::undo_limit`] can keep.
|
||||
pub const MAX_UNDO_LIMIT: usize = 10_000;
|
||||
|
||||
/// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
|
||||
@@ -84,6 +84,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
/// full of entries can't stall start-up.
|
||||
pub const MAX_REOPEN: usize = 32;
|
||||
/// Largest entry read back.
|
||||
const MAX_ENTRY_BYTES: u64 = 2 << 30;
|
||||
const MAX_META_BYTES: u64 = 64 << 10;
|
||||
|
||||
/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an
|
||||
/// entry belongs would block start-up forever.
|
||||
fn read_regular(p: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let m = std::fs::metadata(p).ok()?;
|
||||
(m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten()
|
||||
}
|
||||
|
||||
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
@@ -136,11 +150,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = std::fs::read_to_string(p.with_extension("json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or(json!({}));
|
||||
out.push((uid, meta));
|
||||
@@ -153,11 +169,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
/// Entries stay on disk, now owned by this instance: the presentation keeps the entry's uid, so
|
||||
/// the next recovery save replaces the snapshot and saving or closing it removes it. Entries of a
|
||||
/// running instance, unreadable entries and entries already open here are left alone. Returns the
|
||||
/// opened documents' indices.
|
||||
/// opened documents' indices. At most [`MAX_REOPEN`] are opened per start, the newest first; the rest
|
||||
/// wait for the next one.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
let me = instance_id();
|
||||
let mut opened = Vec::new();
|
||||
for (uid, mut meta) in list(dir) {
|
||||
let mut entries = list(dir);
|
||||
entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid)));
|
||||
for (uid, mut meta) in entries {
|
||||
if opened.len() >= MAX_REOPEN {
|
||||
break;
|
||||
}
|
||||
if s.documents().iter().any(|d| d.uid == uid) {
|
||||
continue;
|
||||
}
|
||||
@@ -168,7 +190,7 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
continue;
|
||||
}
|
||||
let (doc, meta_path) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue };
|
||||
let Ok(mut d) = deckcraft_format::load(&bytes) else { continue };
|
||||
deckcraft_format::repair(&mut d);
|
||||
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
@@ -311,4 +333,36 @@ mod tests {
|
||||
assert_eq!(v.as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked
|
||||
/// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn hostile_recovery_folders_dont_stall_start_up() {
|
||||
let dir = temp("recovery-hostile");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
save(&s, &dir).unwrap();
|
||||
let (good, _) = files(&dir, s.doc().unwrap().uid);
|
||||
for i in 1..=(MAX_REOPEN as u64 + 3) {
|
||||
std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap();
|
||||
}
|
||||
for name in ["5.deckcraft", "6.json"] {
|
||||
let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status();
|
||||
}
|
||||
std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let d = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut s2 = Session::new();
|
||||
let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0));
|
||||
});
|
||||
let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung");
|
||||
assert_eq!(n, MAX_REOPEN);
|
||||
// Every entry stays on disk (main keeps snapshots until a newer save); the 32 opened plus
|
||||
// the rest that wait for the next start.
|
||||
assert_eq!(list(&dir).len(), MAX_REOPEN + 5, "the rest wait for the next start");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,62 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_copy_is_html_and_pages_html_pastes_bold() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello"})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
let v = s.execute("edit.copy", &json!({})).unwrap();
|
||||
let html = v["html"].as_str().unwrap();
|
||||
assert!(html.contains("<b>"), "{html}");
|
||||
assert_eq!(v["text"], "Hello");
|
||||
// The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed.
|
||||
s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap();
|
||||
s.execute("edit.paste", &json!({"text": "Hello", "html": "<p>Hello</p>"})).unwrap();
|
||||
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.text(), "HelloHello");
|
||||
assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}");
|
||||
s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap();
|
||||
let caret = s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(caret["text"], "");
|
||||
assert_eq!(caret["html"], "");
|
||||
|
||||
let mut ext = session();
|
||||
ext.execute("edit.paste", &json!({"text": "Hello", "html": "<p style=\"font-weight: bold\">Hello</p>"})).unwrap();
|
||||
let pasted = ext
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello"))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}");
|
||||
|
||||
let mut mismatched = session();
|
||||
mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "<b>Nope</b>"})).unwrap();
|
||||
let plain = mismatched
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi"))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_roundtrip_positions() {
|
||||
let mut s = session();
|
||||
@@ -287,6 +343,34 @@ fn base64_roundtrip() {
|
||||
assert!(cmd::base64_decode("!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_slide_body_tabs_match_text_set() {
|
||||
let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck";
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap();
|
||||
let levels = |s: &Session| {
|
||||
let slide = s.doc().unwrap().current_slide().unwrap();
|
||||
let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap();
|
||||
let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect();
|
||||
(sh.id.0, rows)
|
||||
};
|
||||
let (id, from_new) = levels(&s);
|
||||
assert_eq!(
|
||||
from_new,
|
||||
vec![
|
||||
(0, "Install from DMG".to_string()),
|
||||
(0, "Launch app".to_string()),
|
||||
(1, "Check signing".to_string()),
|
||||
(2, "Deep".to_string()),
|
||||
(0, "Build a deck".to_string()),
|
||||
]
|
||||
);
|
||||
s.execute("text.set", &json!({"id": id, "text": body})).unwrap();
|
||||
assert_eq!(levels(&s).1, from_new);
|
||||
s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap();
|
||||
assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_change_keeps_text() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -369,7 +369,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(&mut self, label: &str) {
|
||||
pub(crate) fn begin(&mut self, label: &str) {
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.is_none()
|
||||
{
|
||||
@@ -386,6 +386,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
|
||||
pub(crate) fn commit_named(&mut self, label: &str) {
|
||||
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
|
||||
if ours {
|
||||
self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
|
||||
Reference in New Issue
Block a user