mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
Merge PR #243 from pierrevbc: Canvas: drag the frame of a text box being edited to move it
# Conflicts: # crates/engine/src/tests.rs # crates/engine/src/tools.rs
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -217,6 +218,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
|
||||
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
|
||||
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
|
||||
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
|
||||
@@ -2277,3 +2277,141 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
|
||||
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
|
||||
}
|
||||
|
||||
/// A blank slide holding one text box being edited, with smart guides and grid snapping off so
|
||||
/// drags move by exactly the pointer delta. Returns the session, the box id and its box.
|
||||
fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();
|
||||
if rot != 0.0 {
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap();
|
||||
}
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let x = text_box_xfrm(&s, id);
|
||||
(s, id, x)
|
||||
}
|
||||
|
||||
/// Slide point for a point in the box's own (rotated) frame.
|
||||
fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) {
|
||||
let p = x.affine() * deckcraft_geom::Point::new(lx, ly);
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
let down = s.pointer(ev(PointerKind::Down, from)).unwrap();
|
||||
let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
|
||||
s.pointer(ev(PointerKind::Drag, mid)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, to)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, to)).unwrap();
|
||||
down
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_frame_of_edited_text_moves_the_box() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
assert!(x.h > 12.0, "{x:?}");
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// Hovering the band on either side of the edge says "move"; the inside says "text".
|
||||
for ly in [-2.0, 2.0, x.h + 2.0] {
|
||||
let (px, py) = on_box(&x, x.w / 4.0, ly);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}");
|
||||
}
|
||||
let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true }));
|
||||
|
||||
// Press on the top edge, just outside, and drag: the box moves, text editing ends.
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let from = on_box(&x, x.w / 4.0, -1.0);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
|
||||
assert_eq!(down["hit"], json!({"kind": "frame", "id": id}));
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!((moved.w, moved.h), (x.w, x.h));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
// One undo step, which puts the box back.
|
||||
assert_eq!(st.history.undo.len(), undo_before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
|
||||
// The inner side of the band works too; a click there without a drag selects the box whole
|
||||
// and changes nothing.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0);
|
||||
let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0 };
|
||||
s.pointer(ev(PointerKind::Down)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up)).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_edited_text_selects_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0));
|
||||
assert!(down.get("caret").is_some(), "{down}");
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.as_ref().expect("still editing");
|
||||
assert!(t.is_range(), "{t:?}");
|
||||
assert_eq!(t.ordered(), ((0, 0), (0, 11)));
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_of_rotated_edited_text_follows_the_rotation() {
|
||||
let (mut s, id, x) = editing_text_box(30.0);
|
||||
assert_eq!(x.rot, 30.0);
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation.
|
||||
let from = on_box(&x, x.w / 4.0, x.h + 1.0);
|
||||
assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0));
|
||||
assert_eq!(down["hit"]["kind"], "frame");
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!(moved.rot, 30.0);
|
||||
// Inside the rotated box, pressing still places the caret.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let mx = text_box_xfrm(&s, id);
|
||||
let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0);
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
assert!(r.get("caret").is_some(), "{r}");
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
// Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press
|
||||
// there leaves editing instead of moving the caret.
|
||||
let off = on_box(&mx, mx.w / 2.0, -mx.h);
|
||||
let off_pt = deckcraft_geom::Point::new(off.0, off.1);
|
||||
assert!(mx.bounds().contains(off_pt));
|
||||
assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
assert!(r.get("caret").is_none(), "{r}");
|
||||
assert!(s.doc().unwrap().selection.text.is_none());
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_handles_beat_the_frame_of_edited_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
// The SE corner lies in the frame band, but its handle wins.
|
||||
let from = on_box(&x, x.w, x.h);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1));
|
||||
assert_eq!(down["hit"]["kind"], "resize");
|
||||
let sized = text_box_xfrm(&s, id);
|
||||
assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}");
|
||||
assert_eq!((sized.x, sized.y), (x.x, x.y));
|
||||
}
|
||||
|
||||
+80
-21
@@ -101,6 +101,11 @@ pub enum Hit {
|
||||
id: ShapeId,
|
||||
text: bool,
|
||||
},
|
||||
/// The frame of the box whose text is being edited (a band on either side of its edge):
|
||||
/// pressing there leaves text editing and drags the box, like PowerPoint.
|
||||
Frame {
|
||||
id: ShapeId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -305,6 +310,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame of the text being edited: grabbing it moves the box.
|
||||
if let Some(e) = edited_box(st)
|
||||
&& e.movable
|
||||
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
||||
{
|
||||
return Some(Hit::Frame { id: e.id });
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
@@ -418,29 +430,25 @@ impl Session {
|
||||
ToolKind::Select => {}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
if let Some(t) = st.selection.text.clone()
|
||||
&& !t.notes
|
||||
&& t.cell.is_none()
|
||||
&& let Some(sh) = st.shape(t.shape)
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection. Its
|
||||
// handles and its frame (see `Hit::Frame`) win: they size, rotate or move the box.
|
||||
if let Some(e) = edited_box(st)
|
||||
&& edit_zone(&e.xfrm, p, tol) != EditZone::Outside
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. }))
|
||||
&& let Some(sh) = st.shape(e.id)
|
||||
{
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if x.bounds().inflate(tol, tol).contains(p)
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. }))
|
||||
{
|
||||
let pos = text_pos(st, sh, p);
|
||||
let shift = mods.shift;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = pos;
|
||||
if !shift {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
let pos = text_pos(st, sh, p);
|
||||
let shift = mods.shift;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = pos;
|
||||
if !shift {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: e.id });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
// Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
|
||||
@@ -499,6 +507,17 @@ impl Session {
|
||||
self.begin("Adjust Shape");
|
||||
self.tool.drag = Some(Drag::Adjust { id, index, orig });
|
||||
}
|
||||
Some(Hit::Frame { id }) => {
|
||||
// Like PowerPoint: the box leaves text editing and is selected whole (a click
|
||||
// without a drag stops there), and dragging moves it as one undo step.
|
||||
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
|
||||
}
|
||||
Some(Hit::Shape { id, text }) => {
|
||||
let selected = st.selection.shapes.contains(&id);
|
||||
let toggle = mods.shift || mods.cmd;
|
||||
@@ -1302,6 +1321,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<S
|
||||
None
|
||||
}
|
||||
|
||||
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
|
||||
struct EditedBox {
|
||||
id: ShapeId,
|
||||
xfrm: Xfrm,
|
||||
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
|
||||
let t = st.selection.text.as_ref()?;
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
|
||||
}
|
||||
|
||||
/// Where a point falls against the box of the text being edited.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EditZone {
|
||||
/// Well inside: the caret goes there.
|
||||
Inside,
|
||||
/// On the frame: a band `tol` wide on either side of the box edge.
|
||||
Frame,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
|
||||
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
|
||||
fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
|
||||
let l = x.affine().inverse() * p;
|
||||
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
|
||||
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
|
||||
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
|
||||
return EditZone::Outside;
|
||||
}
|
||||
let inner = tol.min(w / 4.0).min(h / 4.0);
|
||||
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
|
||||
}
|
||||
|
||||
fn contains_rect(outer: Rect, inner: Rect) -> bool {
|
||||
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
egui::CursorIcon::Move
|
||||
}
|
||||
}
|
||||
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => egui::CursorIcon::Default,
|
||||
};
|
||||
if resp.hovered() {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -124,6 +125,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Drag the frame of a text box being edited to move it | yes | yes |
|
||||
| Exact size/position, lock aspect | yes | yes |
|
||||
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
|
||||
| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles |
|
||||
@@ -220,6 +223,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout |
|
||||
| 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip |
|
||||
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
Reference in New Issue
Block a user