mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 12:07:02 -06:00
Merge PR #539 from NikitkaRa: Draw: Ink to Shape converts pen strokes into shapes
# Conflicts: # crates/engine/src/cmd/shape.rs # crates/engine/src/lib.rs # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -29,6 +29,15 @@ 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),
|
||||
@@ -1246,3 +1255,79 @@ 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}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod inkshape;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the
|
||||
//! mouse, press the button, get a rectangle shape.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn pen_a_rectangle_then_ink_to_shape() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.tab = "Draw".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Black Pen").click();
|
||||
h.run_steps(2);
|
||||
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k);
|
||||
let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)];
|
||||
h.hover_at(at(200.0, 150.0));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
for w in corners.windows(2) {
|
||||
for i in 1..=12 {
|
||||
let t = i as f32 / 12.0;
|
||||
let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let has_ink = |h: &Harness<'_, SlideApp>| {
|
||||
h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))
|
||||
};
|
||||
assert!(has_ink(&h), "the pen drew a stroke");
|
||||
h.get_by_label("Ink to Shape").click();
|
||||
h.run_steps(3);
|
||||
assert!(!has_ink(&h), "the only stroke became a shape");
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st.shapes().last().unwrap();
|
||||
assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom);
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}");
|
||||
assert_eq!(st.selection.shapes, vec![sh.id]);
|
||||
if let Ok(img) = h.render() {
|
||||
let _ = img.save("/tmp/ink_to_shape.png");
|
||||
}
|
||||
}
|
||||
@@ -1415,8 +1415,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false);
|
||||
let _ = r.on_hover_text("Ink to Shape (coming soon)");
|
||||
let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })));
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink);
|
||||
if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() {
|
||||
app.session.set_tool(ToolKind::Select);
|
||||
run(app, "draw.inkToShape", json!({}));
|
||||
}
|
||||
let r = big_button(ui, Icon::InkToText, "Ink to\nText", false);
|
||||
let _ = r.on_hover_text("Ink to Text (coming soon)");
|
||||
let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false);
|
||||
@@ -3329,3 +3333,7 @@ mod title_bar_tests {
|
||||
assert_eq!(title_geometry(false), (TITLE_H, 10.0, 26.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inktoshape_tests.rs"]
|
||||
mod inktoshape_tests;
|
||||
|
||||
@@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split:
|
||||
let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max);
|
||||
let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
// Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape").
|
||||
let spoken = label.replace('\n', " ");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken));
|
||||
let painter = ui.painter_at(rect.expand(1.0));
|
||||
let (main_rect, arrow_rect) = if split {
|
||||
(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max)))
|
||||
|
||||
@@ -16,6 +16,8 @@ The summary between `<!-- SUMMARY -->
|
||||
| P2 | 54 | 14 | 15 | 25 | 40% |
|
||||
| P1 | 65 | 39 | 21 | 5 | 76% |
|
||||
| P2 | 54 | 14 | 17 | 23 | 42% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 16 | 25 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
@@ -47,6 +49,8 @@ The summary between `<!-- SUMMARY -->
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
| Undo & clipboard | 100% |
|
||||
| Draw | 50% |
|
||||
| Undo & clipboard | 88% |
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
@@ -240,7 +244,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Review | Compare presentations | P2 | M |
|
||||
| Review | Track changes / version history | P2 | M |
|
||||
| Draw | Ink pens, highlighter, eraser | P1 | P |
|
||||
| Draw | Ink to shape / text | P2 | M |
|
||||
| Draw | Ink to shape / text | P2 | P |
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
|
||||
|
||||
Reference in New Issue
Block a user