mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
dc-show-click-targets: follow animated show click targets
This commit is contained in:
1 parent
f893f6af7d
commit
14eef3aceb
2 files changed
+103
-23
No files matched your search
@@ -1,5 +1,6 @@
|
||||
use super::render::fit;
|
||||
use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use super::{Click, HitShape, Hyperlink, PlacedShape, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use deckcraft_anim::AnimState;
|
||||
|
||||
/// The media-host voice for slide transition sounds (no shape has this id).
|
||||
pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX);
|
||||
@@ -152,6 +153,55 @@ pub(super) struct Under {
|
||||
pub(super) pick: Option<Pick>,
|
||||
}
|
||||
|
||||
/// Convert a slide-space point back into a shape's own coordinates, undoing its animation and
|
||||
/// those of its enclosing groups.
|
||||
fn hit_point(mut point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> {
|
||||
let mut chain = Vec::new();
|
||||
let mut at = Some(index);
|
||||
for _ in 0..=shapes.len() {
|
||||
let Some(i) = at else { break };
|
||||
let shape = shapes.get(i)?;
|
||||
chain.push(i);
|
||||
at = shape.parent;
|
||||
}
|
||||
if at.is_some() {
|
||||
return None;
|
||||
}
|
||||
chain.reverse();
|
||||
let mut centers = Vec::with_capacity(chain.len());
|
||||
for &i in &chain {
|
||||
let r = shapes.get(i)?.rect;
|
||||
let center = transform_point((r.center().x, r.center().y), centers.last().copied());
|
||||
centers.push((center, *states.get(i)?));
|
||||
}
|
||||
for &(center, state) in centers.iter().rev() {
|
||||
point = inverse_transform_point(point, center, state)?;
|
||||
}
|
||||
Some(point)
|
||||
}
|
||||
|
||||
fn transform_point(mut p: (f64, f64), prior: Option<((f64, f64), AnimState)>) -> (f64, f64) {
|
||||
if let Some((center, state)) = prior {
|
||||
let (x, y) = (p.0 - center.0, p.1 - center.1);
|
||||
let angle = state.rotate.to_radians();
|
||||
p = (
|
||||
center.0 + (x * angle.cos() - y * angle.sin()) * state.scale_x + state.offset_x,
|
||||
center.1 + (x * angle.sin() + y * angle.cos()) * state.scale_y + state.offset_y,
|
||||
);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn inverse_transform_point(p: (f64, f64), center: (f64, f64), state: AnimState) -> Option<(f64, f64)> {
|
||||
if state.scale_x.abs() <= f64::EPSILON || state.scale_y.abs() <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p.0 - center.0 - state.offset_x, p.1 - center.1 - state.offset_y);
|
||||
let angle = -state.rotate.to_radians();
|
||||
let (x, y) = (x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos());
|
||||
Some((center.0 + x / state.scale_x, center.1 + y / state.scale_y))
|
||||
}
|
||||
|
||||
impl Under {
|
||||
fn visible(&self, id: ShapeId) -> bool {
|
||||
self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v)
|
||||
@@ -175,31 +225,29 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show
|
||||
}
|
||||
show.timeline(doc);
|
||||
let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None };
|
||||
let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1;
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| HitShape {
|
||||
id: s.id,
|
||||
parent: s.parent,
|
||||
is_group: s.is_group,
|
||||
link: s.link.as_ref().map(|l| l.action.clone()),
|
||||
under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1),
|
||||
})
|
||||
.collect();
|
||||
let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps());
|
||||
let t = if show.state.playing { now - show.step_start } else { 0.0 };
|
||||
let visible: Vec<bool> = shapes
|
||||
let states: Vec<AnimState> = shapes.iter().map(|s| tl.state_ordered(s.id, None, step, t, &plays, &stamps)).collect();
|
||||
let point_for = |i: usize| hit_point((x, y), shapes, &states, i);
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps);
|
||||
a.visible && a.opacity > 0.0
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let under_pointer = point_for(i).is_some_and(|(x, y)| s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1);
|
||||
HitShape { id: s.id, parent: s.parent, is_group: s.is_group, link: s.link.as_ref().map(|l| l.action.clone()), under_pointer }
|
||||
})
|
||||
.collect();
|
||||
let visible: Vec<bool> = states.iter().map(|a| a.visible && a.opacity > 0.0).collect();
|
||||
let mut under = Under { shapes: hits, visible, pick: None };
|
||||
let shown = |id: ShapeId| under.visible(id);
|
||||
// A hyperlink in text applies when its shape is the one on top.
|
||||
let top = show.state.top(doc, tl, &under.shapes, &shown);
|
||||
let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| {
|
||||
let text = links.iter().rev().filter(|l| l.text).find(|l| {
|
||||
let Some(i) = shapes.iter().position(|s| s.id == l.shape) else { return false };
|
||||
let Some((x, y)) = point_for(i) else { return false };
|
||||
if !(l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1) {
|
||||
return false;
|
||||
}
|
||||
let mut at = top;
|
||||
(0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) {
|
||||
Some(h) if h.id == l.shape => true,
|
||||
@@ -217,6 +265,36 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show
|
||||
Some(under)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hit_tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(id: u32, parent: Option<usize>, rect: deckcraft_geom::Rect) -> PlacedShape {
|
||||
PlacedShape { id: ShapeId(id), parent, is_group: parent.is_none(), rect, link: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_moves_the_hit_box() {
|
||||
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 50.0, scale_x: 2.0, ..Default::default() }];
|
||||
|
||||
let moved = hit_point((65.0, 20.0), &shapes, &states, 0);
|
||||
let abandoned = hit_point((15.0, 20.0), &shapes, &states, 0);
|
||||
assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
assert!(!abandoned.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_animation_moves_child_hit_box() {
|
||||
let shapes =
|
||||
[shape(1, None, deckcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), shape(2, Some(0), deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 40.0, ..Default::default() }, AnimState::default()];
|
||||
|
||||
let moved = hit_point((55.0, 20.0), &shapes, &states, 1);
|
||||
assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Advance the clock of the running triggered animations.
|
||||
pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) {
|
||||
|
||||
+8
-6
@@ -1,7 +1,7 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** minor (slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** minor (slide-show click targets follow animated shapes and text links; previous: trivial, empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -219,11 +219,12 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score
|
||||
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
|
||||
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
|
||||
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count).
|
||||
Slide-show click targets now follow animated translations and scales, including text links.
|
||||
Trigger sequences now keep their separate
|
||||
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/it/trigger_steps.rs`).
|
||||
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
|
||||
clickable shapes do not play as authored.
|
||||
- **Impact:** games built around sound effects on buttons or audio-triggered effects do not play
|
||||
as authored.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### Notes and Handout masters
|
||||
@@ -440,6 +441,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open |
|
||||
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||
| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 2026-10-28 | minor | Slide-show click targets follow animated movement and scale for shapes and text links; sound actions and audio triggers remain open |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
|
||||
Reference in new issue
Block a user