From 0bdcd5306104d31120657ed8ae717a4aa605c4c0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 23 Sep 2026 16:10:02 -0500 Subject: [PATCH 1/4] feat(superdeck): animate images between slides --- .../references/authoring.md | 2 + docs/reference/markdown-syntax.mdx | 2 + .../lib/src/builtins/image_widget.dart | 44 +++- .../builders/image_element_builder.dart | 64 +++--- .../lib/src/markdown/image_block_syntax.dart | 3 +- .../src/rendering/blocks/block_provider.dart | 10 +- .../src/rendering/blocks/block_widget.dart | 15 ++ .../blocks/image_hero_positions.dart | 43 ++++ .../lib/src/rendering/slides/slide_view.dart | 9 +- .../lib/src/ui/widgets/hero_element.dart | 4 + .../lib/src/ui/widgets/image_hero_flight.dart | 41 ++++ .../src/markdown/hero_transition_test.dart | 200 ++++++++++++++++++ 12 files changed, 396 insertions(+), 41 deletions(-) create mode 100644 packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart create mode 100644 packages/superdeck/lib/src/ui/widgets/image_hero_flight.dart diff --git a/.agents/skills/superdeck-presentations/references/authoring.md b/.agents/skills/superdeck-presentations/references/authoring.md index 177372a8e..0abab97bc 100644 --- a/.agents/skills/superdeck-presentations/references/authoring.md +++ b/.agents/skills/superdeck-presentations/references/authoring.md @@ -443,6 +443,8 @@ The first valid class name is used as the Hero tag. The class does not need a `h Use the same tag on the element that should animate from one slide to the next. For example, a heading marked `{.heading}` on slide 1 will transition to the heading marked `{.heading}` on slide 2. +Images also transition automatically by their order on consecutive slides, including standalone Markdown images and `@image` blocks. The first image flies to the first image, the second to the second, and so on, even when the layout or source changes. An explicit Markdown image tag overrides its automatic match when a particular image needs a stable identity. + Do not duplicate the same Hero tag on one slide. Flutter Hero transitions require one source/destination element for each tag in a route; if a slide contains two elements marked `{.heading}`, the transition is ambiguous and can fail. Use distinct tags such as `{.heading}` and `{.subheading}` for multiple animated text elements on the same slide. Do not rely on classes beginning with `--`; they are rejected. The class marker is stripped from rendered content and is not a Mix style selector. diff --git a/docs/reference/markdown-syntax.mdx b/docs/reference/markdown-syntax.mdx index 71296d78f..edb70a281 100644 --- a/docs/reference/markdown-syntax.mdx +++ b/docs/reference/markdown-syntax.mdx @@ -227,4 +227,6 @@ Use Markdown class markers to animate matching elements between slides. The firs Use the same tag for the element that should transition from one slide to the next. For example, one `{.heading}` on slide A can animate to one `{.heading}` on slide B. +Standalone Markdown images and `@image` blocks transition automatically by image order: the first image on one slide moves to the first on the next slide, the second to the second, and so on. This also blends different image sources. Use an explicit Markdown class marker when an image needs to match by a named identity instead of its position. Images without a partner on the adjacent slide render normally. + Do not use the same tag twice on one slide. Each Hero tag must identify one element per slide, so use separate tags like `{.heading}` and `{.subheading}` when multiple elements should animate. diff --git a/packages/superdeck/lib/src/builtins/image_widget.dart b/packages/superdeck/lib/src/builtins/image_widget.dart index 0621a9498..0d21328b0 100644 --- a/packages/superdeck/lib/src/builtins/image_widget.dart +++ b/packages/superdeck/lib/src/builtins/image_widget.dart @@ -7,7 +7,10 @@ import 'package:superdeck_core/superdeck_core.dart'; import '../deck/slide_configuration.dart'; import '../markdown/builders/image_element_builder.dart' show isBareAssetKey; import '../rendering/blocks/block_provider.dart'; +import '../rendering/blocks/image_hero_positions.dart'; import '../ui/widgets/cache_image_widget.dart'; +import '../ui/widgets/hero_element.dart'; +import '../ui/widgets/image_hero_flight.dart'; import '../ui/widgets/provider.dart'; import '../ui/widgets/resolved_asset_image.dart'; import '../utils/converters.dart'; @@ -142,6 +145,35 @@ class ImageWidget extends StatelessWidget { : _data = ImageDto.parse(args), _decodedImage = decodedImage; + Widget _withHero( + BuildContext context, + Widget image, + ImageSpec spec, + Size blockSize, + ) { + final slide = InheritedData.maybeOf(context); + if (slide == null || slide.isStaticRendering) return image; + + final block = BlockConfiguration.of(context); + final size = Size( + _data.width ?? blockSize.width, + _data.height ?? blockSize.height, + ); + return HeroElement( + data: ImageElement( + spec: spec, + uri: _data.src, + size: size, + flightImage: image, + ), + child: buildElementHero( + tag: automaticImageHeroTag(block.imageHeroStart), + child: image, + buildFlight: buildImageHeroFlight, + ), + ); + } + @override Widget build(BuildContext context) { final data = BlockConfiguration.of(context); @@ -183,7 +215,10 @@ class ImageWidget extends StatelessWidget { final constrained = hasExplicitSize ? SizedBox(width: _data.width, height: _data.height, child: image) : image; - return Align(alignment: flutterAlignment, child: constrained); + return Align( + alignment: flutterAlignment, + child: _withHero(context, constrained, styleSpec.spec, data.size), + ); } final transformed = Transform.scale( @@ -200,7 +235,12 @@ class ImageWidget extends StatelessWidget { return Align( alignment: flutterAlignment, - child: ClipRect(child: frame), + child: _withHero( + context, + ClipRect(child: frame), + styleSpec.spec, + data.size, + ), ); } } diff --git a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart index 22d3de146..ee908136e 100644 --- a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart @@ -4,9 +4,11 @@ import 'package:markdown/markdown.dart' as md; import 'package:mix/mix.dart'; import '../../deck/slide_configuration.dart'; import '../../rendering/blocks/block_provider.dart'; +import '../../rendering/blocks/image_hero_positions.dart'; import '../../ui/widgets/cache_image_widget.dart'; import '../../ui/widgets/error_widgets.dart'; import '../../ui/widgets/hero_element.dart'; +import '../../ui/widgets/image_hero_flight.dart'; import '../../ui/widgets/provider.dart'; import '../../ui/widgets/resolved_asset_image.dart'; import '../../utils/uri_validator.dart'; @@ -23,6 +25,7 @@ bool isBareAssetKey(Uri uri) => class ImageElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { final StyleSpec styleSpec; + int _nextImageIndex = 0; ImageElementBuilder([this.styleSpec = const StyleSpec(spec: ImageSpec())]); @@ -61,19 +64,26 @@ class ImageElementBuilder extends MarkdownElementBuilder return ErrorWidgets.simple('Invalid image source: ${e.toString()}'); } - final heroTag = element.attributes['hero']; + final block = BlockConfiguration.of(context); + final isStandalone = + element.attributes['data-superdeck-block-image'] == 'true'; + final heroTag = + element.attributes['hero'] ?? + (isStandalone + ? automaticImageHeroTag(block.imageHeroStart + _nextImageIndex) + : null); + if (isStandalone) _nextImageIndex++; // Access BlockConfiguration from the context parameter (available because isBlockElement() is true) - final totalSize = BlockConfiguration.of(context).size; + final totalSize = block.size; // A bare key (e.g. an AI-generated `slide-x-illustration.png`) is resolved - // through the slide's asset cache when one is bound. Hero transitions are - // not applied for cache-resolved images (they have no hero tag in practice). + // through the slide's asset cache when one is bound. final assetCacheStore = InheritedData.maybeOf( context, )?.assetCacheStore; if (assetCacheStore != null && isBareAssetKey(uri)) { - return ConstrainedBox( + final image = ConstrainedBox( constraints: BoxConstraints.tight(totalSize), child: ResolvedAssetImage( assetKey: uri.path, @@ -83,11 +93,23 @@ class ImageElementBuilder extends MarkdownElementBuilder styleSpec: styleSpec, ), ); + return applyHeroIfNeeded( + context: context, + child: image, + heroTag: heroTag, + heroData: ImageElement( + spec: styleSpec.spec, + uri: uri, + size: totalSize, + flightImage: image, + ), + buildFlight: buildImageHeroFlight, + ); } return StyleSpecBuilder( builder: (builderContext, spec) { - Widget imageWidget = ConstrainedBox( + final imageWidget = ConstrainedBox( constraints: BoxConstraints.tight(totalSize), child: CachedImage(uri: uri, styleSpec: styleSpec), ); @@ -97,35 +119,7 @@ class ImageElementBuilder extends MarkdownElementBuilder child: imageWidget, heroTag: heroTag, heroData: ImageElement(spec: spec, uri: uri, size: totalSize), - buildFlight: (flightContext, from, to, t) { - final fromSize = from.size; - final fromSpec = from.spec; - final fromUri = from.uri; - - final interpolatedSize = Size.lerp(fromSize, to.size, t)!; - final interpolatedSpec = fromSpec.lerp(to.spec, t); - Widget image(Uri uri) => CachedImage( - key: ValueKey(uri), - uri: uri, - styleSpec: StyleSpec(spec: interpolatedSpec), - ); - final blend = Curves.easeInOut.transform(t); - - return SizedBox.fromSize( - size: interpolatedSize, - child: fromUri == to.uri - ? image(fromUri) - : Stack( - fit: .expand, - children: [ - // Mount both providers for the entire flight, including at - // zero opacity, so changing alpha never starts a new load. - Opacity(opacity: 1 - blend, child: image(fromUri)), - Opacity(opacity: blend, child: image(to.uri)), - ], - ), - ); - }, + buildFlight: buildImageHeroFlight, ); }, styleSpec: styleSpec, diff --git a/packages/superdeck/lib/src/markdown/image_block_syntax.dart b/packages/superdeck/lib/src/markdown/image_block_syntax.dart index 000540d91..e47b40221 100644 --- a/packages/superdeck/lib/src/markdown/image_block_syntax.dart +++ b/packages/superdeck/lib/src/markdown/image_block_syntax.dart @@ -49,7 +49,8 @@ class ImageBlockSyntax extends md.BlockSyntax { // Create a top-level element (NOT wrapped in

) final element = md.Element.empty('img') ..attributes['src'] = src - ..attributes['alt'] = alt; + ..attributes['alt'] = alt + ..attributes['data-superdeck-block-image'] = 'true'; if (title != null) { element.attributes['title'] = title; diff --git a/packages/superdeck/lib/src/rendering/blocks/block_provider.dart b/packages/superdeck/lib/src/rendering/blocks/block_provider.dart index 789ed3d90..8b5912799 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_provider.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_provider.dart @@ -18,6 +18,7 @@ class BlockConfiguration { required this.size, required this.align, required this.runtimeKey, + this.imageHeroStart = 0, }); final SlideSpec spec; @@ -27,17 +28,22 @@ class BlockConfiguration { /// Slide-local identity derived from slide key + section/block indices. final String runtimeKey; + /// Ordinal of this block's first image within the slide. + final int imageHeroStart; + @override bool operator ==(Object other) { return other is BlockConfiguration && other.spec == spec && other.size == size && other.align == align && - other.runtimeKey == runtimeKey; + other.runtimeKey == runtimeKey && + other.imageHeroStart == imageHeroStart; } @override - int get hashCode => Object.hash(spec, size, align, runtimeKey); + int get hashCode => + Object.hash(spec, size, align, runtimeKey, imageHeroStart); static BlockConfiguration of(BuildContext context) { final data = InheritedData.maybeOf(context); diff --git a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart index bc8bdff98..f54eac005 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart @@ -13,6 +13,7 @@ import '../../ui/widgets/provider.dart'; import '../../utils/converters.dart'; import '../layout_debug_overlay.dart'; import 'block_provider.dart'; +import 'image_hero_positions.dart'; import 'markdown_viewer.dart'; /// Private container widget that provides shared block infrastructure. @@ -24,6 +25,7 @@ class _BlockContainer extends StatefulWidget { required this.align, required this.configuration, required this.runtimeKey, + required this.imageHeroStart, required this.child, }); @@ -31,6 +33,7 @@ class _BlockContainer extends StatefulWidget { final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; + final int imageHeroStart; final Widget child; @override @@ -112,6 +115,7 @@ class _BlockContainerState extends State<_BlockContainer> { spec: spec, size: constraints.biggest, runtimeKey: widget.runtimeKey, + imageHeroStart: widget.imageHeroStart, ); Widget innerContent = widget.child; @@ -270,12 +274,14 @@ class BlockWidget extends StatelessWidget { required this.align, required this.configuration, required this.runtimeKey, + required this.imageHeroStart, }); final ContentBlock block; final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; + final int imageHeroStart; @override Widget build(BuildContext context) { @@ -284,6 +290,7 @@ class BlockWidget extends StatelessWidget { align: align, configuration: configuration, runtimeKey: runtimeKey, + imageHeroStart: imageHeroStart, child: _ContentBlockChild( content: block.content, allowVerticalOverflow: @@ -301,12 +308,14 @@ class CustomBlockWidget extends StatelessWidget { required this.align, required this.configuration, required this.runtimeKey, + required this.imageHeroStart, }); final WidgetBlock block; final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; + final int imageHeroStart; @override Widget build(BuildContext context) { @@ -317,6 +326,7 @@ class CustomBlockWidget extends StatelessWidget { align: align, configuration: configuration, runtimeKey: runtimeKey, + imageHeroStart: imageHeroStart, child: _CustomBlockChild(block: block), ), ); @@ -378,6 +388,9 @@ class SectionWidget extends StatelessWidget { sectionIndex, blockIndex, ); + final imageHeroStart = InheritedData.of( + context, + ).start(sectionIndex, blockIndex); Widget blockWidget = switch (block) { WidgetBlock b => CustomBlockWidget( @@ -385,12 +398,14 @@ class SectionWidget extends StatelessWidget { align: align, configuration: configuration, runtimeKey: runtimeKey, + imageHeroStart: imageHeroStart, ), ContentBlock b => BlockWidget( block: b, align: align, configuration: configuration, runtimeKey: runtimeKey, + imageHeroStart: imageHeroStart, ), }; diff --git a/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart b/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart new file mode 100644 index 000000000..031d0527f --- /dev/null +++ b/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart @@ -0,0 +1,43 @@ +import 'package:markdown/markdown.dart' as md; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../markdown/image_block_syntax.dart'; + +/// Positions image Heroes by their order in a slide, regardless of layout. +class ImageHeroPositions { + final Map<(int, int), int> _starts = {}; + + ImageHeroPositions(Slide slide) { + var nextIndex = 0; + for (final (sectionIndex, section) in slide.sections.indexed) { + for (final (blockIndex, block) in section.blocks.indexed) { + _starts[(sectionIndex, blockIndex)] = nextIndex; + nextIndex += switch (block) { + WidgetBlock(name: 'image') => 1, + ContentBlock(:final content) => _countMarkdownImages(content), + _ => 0, + }; + } + } + } + + static int _countMarkdownImages(String content) { + final document = md.Document( + extensionSet: md.ExtensionSet.gitHubWeb, + blockSyntaxes: [ImageBlockSyntax()], + ); + // Inline images become alt text in MarkdownViewer, so only top-level + // standalone images consume a Hero position. + return document + .parseLines(content.split('\n')) + .whereType() + .where((element) => element.tag == 'img') + .length; + } + + int start(int sectionIndex, int blockIndex) => + _starts[(sectionIndex, blockIndex)] ?? 0; +} + +// Colons keep generated tags disjoint from author-defined class identifiers. +String automaticImageHeroTag(int index) => 'superdeck:image:$index'; diff --git a/packages/superdeck/lib/src/rendering/slides/slide_view.dart b/packages/superdeck/lib/src/rendering/slides/slide_view.dart index 2325d6142..4b5f437a8 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_view.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_view.dart @@ -4,8 +4,10 @@ import 'package:superdeck_core/superdeck_core.dart'; import '../../deck/slide_configuration.dart'; import '../../styling/components/slide.dart'; +import '../../ui/widgets/provider.dart'; import '../../utils/constants.dart'; import '../blocks/block_widget.dart'; +import '../blocks/image_hero_positions.dart'; import '../layout_debug_overlay.dart'; class SlideView extends StatelessWidget { @@ -137,7 +139,12 @@ class SlideView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _renderPreferredSize(header, headerHeight), - Expanded(child: _renderSections(slide)), + Expanded( + child: InheritedData( + data: ImageHeroPositions(slide.slide), + child: _renderSections(slide), + ), + ), _renderPreferredSize(footer, footerHeight), ], ); diff --git a/packages/superdeck/lib/src/ui/widgets/hero_element.dart b/packages/superdeck/lib/src/ui/widgets/hero_element.dart index 28ab50954..e4d18ac90 100644 --- a/packages/superdeck/lib/src/ui/widgets/hero_element.dart +++ b/packages/superdeck/lib/src/ui/widgets/hero_element.dart @@ -32,10 +32,14 @@ class ImageElement { final Uri uri; final Size size; + /// Rendered endpoint when an image uses an asset cache or `@image` layout. + final Widget? flightImage; + const ImageElement({ required this.spec, required this.uri, required this.size, + this.flightImage, }); } diff --git a/packages/superdeck/lib/src/ui/widgets/image_hero_flight.dart b/packages/superdeck/lib/src/ui/widgets/image_hero_flight.dart new file mode 100644 index 000000000..782ca5674 --- /dev/null +++ b/packages/superdeck/lib/src/ui/widgets/image_hero_flight.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +import 'cache_image_widget.dart'; +import 'hero_element.dart'; + +/// Moves an image between frames while blending different image sources. +Widget buildImageHeroFlight( + BuildContext context, + ImageElement from, + ImageElement to, + double t, +) { + final spec = from.spec.lerp(to.spec, t); + final blend = Curves.easeInOut.transform(t); + + Widget image(ImageElement element) => + element.flightImage ?? + CachedImage( + key: ValueKey(element.uri), + uri: element.uri, + styleSpec: StyleSpec(spec: spec), + ); + + // Hero's overlay already interpolates the measured endpoint rectangles. + // A second size tween from block dimensions is wrong for intrinsically sized + // images and images with only one explicit dimension. + return SizedBox.expand( + key: const ValueKey('image-hero-flight'), + child: + from.uri == to.uri && from.flightImage == null && to.flightImage == null + ? image(from) + : Stack( + fit: StackFit.expand, + children: [ + Opacity(opacity: 1 - blend, child: image(from)), + Opacity(opacity: blend, child: image(to)), + ], + ), + ); +} diff --git a/packages/superdeck/test/src/markdown/hero_transition_test.dart b/packages/superdeck/test/src/markdown/hero_transition_test.dart index 3a1d838e2..f63183ed7 100644 --- a/packages/superdeck/test/src/markdown/hero_transition_test.dart +++ b/packages/superdeck/test/src/markdown/hero_transition_test.dart @@ -9,6 +9,7 @@ import 'package:superdeck/src/styling/components/slide.dart'; import 'package:superdeck/src/styling/default_style.dart'; import 'package:superdeck/src/ui/widgets/cache_image_widget.dart'; import 'package:superdeck/src/ui/widgets/hero_element.dart'; +import 'package:superdeck/src/ui/widgets/image_hero_flight.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -238,6 +239,200 @@ void main() { expect(_imageHeroData(tester).single.size, toData.size); }); + testWidgets('untagged Markdown and widget images fly by slide order', ( + tester, + ) async { + _setSlideViewport(tester); + final from = Slide( + key: 'automatic-images-from', + sections: [ + SectionBlock([ + ContentBlock('![First]($_imageUri)'), + WidgetBlock( + name: 'image', + args: {'src': 'https://example.com/second.png'}, + ), + ]), + ], + ); + final to = Slide( + key: 'automatic-images-to', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'image', + args: {'src': 'https://example.com/third.png'}, + ), + ]), + SectionBlock([ + ContentBlock('![Fourth](https://example.com/fourth.png)'), + ]), + ], + ); + + await _pumpHeroRoutes(tester, from: from, to: to); + expect(_imageHeroTags(tester), {'superdeck:image:0', 'superdeck:image:1'}); + + _navigateToNextSlide(tester); + await tester.pump(); + await tester.pump(_transitionDuration ~/ 2); + expect(tester.takeException(), isNull); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(_imageHeroTags(tester), {'superdeck:image:0', 'superdeck:image:1'}); + expect( + _imageHeroData(tester).map((image) => image.uri.toString()).toSet(), + {'https://example.com/third.png', 'https://example.com/fourth.png'}, + ); + + tester.state(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(_transitionDuration ~/ 2); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + expect(_imageHeroTags(tester), {'superdeck:image:0', 'superdeck:image:1'}); + }); + + testWidgets('explicit image Hero tags override their automatic positions', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'explicit-image-tags', + sections: [ + SectionBlock([ + ContentBlock( + 'Inline ![icon]($_imageUri) stays text.\n\n' + '![First]($_imageUri) {.chosen}\n\n' + '![Second](https://example.com/second.png)', + ), + WidgetBlock( + name: 'image', + args: {'src': 'https://example.com/third.png'}, + ), + ]), + ], + ), + ); + expect(_imageHeroTags(tester), { + 'chosen', + 'superdeck:image:1', + 'superdeck:image:2', + }); + }); + + testWidgets('static slide capture excludes automatic image Heroes', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'static-images', + sections: [ + SectionBlock([ + ContentBlock('![Markdown]($_imageUri)'), + WidgetBlock( + name: 'image', + args: {'src': 'https://example.com/widget.png'}, + ), + ]), + ], + ), + isStaticRendering: true, + ); + + expect(find.byType(Hero), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('image flight follows the rendered Hero frame', (tester) async { + _setSlideViewport(tester); + final from = Slide( + key: 'sized-image-from', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'image', + args: {'src': _imageUri, 'width': 120, 'height': 80}, + ), + ]), + ], + ); + final to = Slide( + key: 'sized-image-to', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'image', + args: {'src': _imageUri, 'width': 240, 'height': 160}, + ), + ]), + ], + ); + await _pumpHeroRoutes(tester, from: from, to: to); + + final fromSize = tester.getSize(find.byType(Hero)); + expect(fromSize.width, 120); + expect(fromSize.height, 80); + _navigateToNextSlide(tester); + await tester.pump(); + await tester.pump(_transitionDuration ~/ 2); + + final flightSize = tester.getSize( + find.byKey(const ValueKey('image-hero-flight')), + ); + expect(flightSize.width, greaterThan(fromSize.width)); + expect(flightSize.height, greaterThan(fromSize.height)); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + final toSize = tester.getSize(find.byType(Hero)); + expect(toSize.width, 240); + expect(toSize.height, 160); + expect(flightSize.width, lessThan(toSize.width)); + expect(flightSize.height, lessThan(toSize.height)); + }); + + testWidgets('image shuttle uses overlay bounds over stale block dimensions', ( + tester, + ) async { + final from = ImageElement( + spec: const ImageSpec(), + uri: Uri.parse(_imageUri), + size: const Size(120, 540), + flightImage: const ColoredBox(color: Colors.red), + ); + final to = ImageElement( + spec: const ImageSpec(), + uri: Uri.parse(_imageUri), + size: const Size(240, 540), + flightImage: const ColoredBox(color: Colors.blue), + ); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 180, + height: 120, + child: Builder( + builder: (context) => + buildImageHeroFlight(context, from, to, 0.5), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byKey(const ValueKey('image-hero-flight'))), + const Size(180, 120), + ); + expect(tester.takeException(), isNull); + }); + testWidgets( 'text Hero blends resolved endpoint styles without changing layout', (tester) async { @@ -674,6 +869,11 @@ List _imageHeroData( .toList(); } +Set _imageHeroTags(WidgetTester tester) => tester + .widgetList(find.byType(Hero)) + .map((hero) => hero.tag.toString()) + .toSet(); + void _setSlideViewport(WidgetTester tester) { tester.view.physicalSize = const Size(1280, 720); tester.view.devicePixelRatio = 1; From a5e593c160e7a23e1793da3c6ca1c7f57e75eb2a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 25 Sep 2026 14:55:05 -0400 Subject: [PATCH 2/4] fix(superdeck): tag only top-level images for automatic Hero positions ImageHeroPositions counted only top-level standalone images, while ImageBlockSyntax stamped every standalone image, including ones nested in blockquotes, alerts, and list items. A quoted image next to a top-level image therefore produced two `superdeck:image:0` Heroes and Flutter threw "multiple heroes that share the same tag" on the next slide transition. Alerts were off by one because their nested body reused the same image builder counter. Stamp the automatic position only when the image is parsed at the top level, give alert bodies a nested syntax list that never stamps it, and count positions with the same shared syntax list the renderer uses. Nested images still render and still honor explicit `{.tag}` markers. --- .../builders/alert_element_builder.dart | 2 +- .../builders/image_element_builder.dart | 4 ++ .../lib/src/markdown/image_block_syntax.dart | 19 +++++-- .../markdown_element_builders_registry.dart | 34 ++++++++---- .../blocks/image_hero_positions.dart | 14 +++-- .../src/markdown/hero_transition_test.dart | 53 +++++++++++++++++++ 6 files changed, 107 insertions(+), 19 deletions(-) diff --git a/packages/superdeck/lib/src/markdown/builders/alert_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/alert_element_builder.dart index 7ab99f5d4..28fc6abe4 100644 --- a/packages/superdeck/lib/src/markdown/builders/alert_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/alert_element_builder.dart @@ -146,7 +146,7 @@ class AlertElementBuilder extends MarkdownElementBuilder { child: MarkdownBody( data: rawMarkdown.trimRight(), extensionSet: extensionSet, - blockSyntaxes: registry.blockSyntaxes, + blockSyntaxes: registry.nestedBlockSyntaxes, inlineSyntaxes: registry.inlineSyntaxes, builders: registry.builders, paddingBuilders: registry.paddingBuilders, diff --git a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart index ee908136e..51a61b9b4 100644 --- a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart @@ -25,6 +25,10 @@ bool isBareAssetKey(Uri uri) => class ImageElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { final StyleSpec styleSpec; + + // Restarts only when `_MarkdownBuilder` recreates the registry that owns this + // builder. Nested bodies, such as alerts, reuse this builder but parse with + // `nestedBlockSyntaxes`, so their images never advance the count. int _nextImageIndex = 0; ImageElementBuilder([this.styleSpec = const StyleSpec(spec: ImageSpec())]); diff --git a/packages/superdeck/lib/src/markdown/image_block_syntax.dart b/packages/superdeck/lib/src/markdown/image_block_syntax.dart index e47b40221..1ccb98744 100644 --- a/packages/superdeck/lib/src/markdown/image_block_syntax.dart +++ b/packages/superdeck/lib/src/markdown/image_block_syntax.dart @@ -13,7 +13,15 @@ import 'package:markdown/markdown.dart' as md; /// /// Note: Only standalone images are supported. Inline images within text /// (e.g., `See ![icon](x.png) here`) are not handled by this syntax. +/// +/// Only top-level standalone images take an automatic Hero position. Standalone +/// images nested in blockquotes, alerts, or list items still render, but match +/// across slides only through an explicit `{.hero}` tag. class ImageBlockSyntax extends md.BlockSyntax { + /// Whether top-level images parsed by this syntax take an automatic Hero + /// position. False for Markdown re-rendered inside another element. + final bool assignsHeroPosition; + /// Pattern matches standalone image lines: `![alt](url)` with optional hero tag /// /// Captures: @@ -25,7 +33,7 @@ class ImageBlockSyntax extends md.BlockSyntax { r'^\s*!\[([^\]]*)\]\(([^)]+?)(?:\s+"([^"]*)")?\)\s*(?:\{\.([a-zA-Z][\w-]*)\})?\s*$', ); - ImageBlockSyntax(); + ImageBlockSyntax({this.assignsHeroPosition = true}); @override RegExp get pattern => _pattern; @@ -49,8 +57,13 @@ class ImageBlockSyntax extends md.BlockSyntax { // Create a top-level element (NOT wrapped in

) final element = md.Element.empty('img') ..attributes['src'] = src - ..attributes['alt'] = alt - ..attributes['data-superdeck-block-image'] = 'true'; + ..attributes['alt'] = alt; + + // Blockquotes, alerts, and lists parse their children with themselves as + // the parent syntax, so a null parent means a top-level image. + if (assignsHeroPosition && parser.parentSyntax == null) { + element.attributes['data-superdeck-block-image'] = 'true'; + } if (title != null) { element.attributes['title'] = title; diff --git a/packages/superdeck/lib/src/markdown/markdown_element_builders_registry.dart b/packages/superdeck/lib/src/markdown/markdown_element_builders_registry.dart index f46315e9c..7a0fc3c39 100644 --- a/packages/superdeck/lib/src/markdown/markdown_element_builders_registry.dart +++ b/packages/superdeck/lib/src/markdown/markdown_element_builders_registry.dart @@ -12,22 +12,36 @@ import 'builders/image_element_builder.dart'; import 'builders/text_element_builder.dart'; import 'image_block_syntax.dart'; +/// Custom block-level syntaxes for a slide's Markdown. +/// +/// [ImageBlockSyntax] must be first to intercept standalone image lines +/// before they get wrapped in `

` tags by the default paragraph parser. +/// `ImageHeroPositions` counts images with the default list, so its automatic +/// Hero positions match the ones assigned while rendering. +List markdownBlockSyntaxes({bool assignsHeroPosition = true}) { + return [ + ImageBlockSyntax(assignsHeroPosition: assignsHeroPosition), + const HeaderTagSyntax(), + const HeroFencedCodeBlockSyntax(), + const AlertBlockSyntax(), + ]; +} + /// Registry for markdown element builders, syntaxes, and layout builders. class SpecMarkdownBuilders { final SlideSpec spec; SpecMarkdownBuilders(this.spec); - /// Custom block-level syntaxes. - /// - /// [ImageBlockSyntax] must be first to intercept standalone image lines - /// before they get wrapped in `

` tags by the default paragraph parser. - final List blockSyntaxes = [ - ImageBlockSyntax(), - const HeaderTagSyntax(), - const HeroFencedCodeBlockSyntax(), - const AlertBlockSyntax(), - ]; + /// Custom block-level syntaxes for a block's own Markdown. + final List blockSyntaxes = markdownBlockSyntaxes(); + + /// Custom block-level syntaxes for Markdown re-rendered inside another + /// element, such as an alert body, where images take no automatic Hero + /// position. + final List nestedBlockSyntaxes = markdownBlockSyntaxes( + assignsHeroPosition: false, + ); /// Custom inline syntaxes. final List inlineSyntaxes = [ImageHeroSyntax()]; diff --git a/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart b/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart index 031d0527f..4e0683740 100644 --- a/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart +++ b/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart @@ -1,7 +1,7 @@ import 'package:markdown/markdown.dart' as md; import 'package:superdeck_core/superdeck_core.dart'; -import '../../markdown/image_block_syntax.dart'; +import '../../markdown/markdown_element_builders_registry.dart'; /// Positions image Heroes by their order in a slide, regardless of layout. class ImageHeroPositions { @@ -24,14 +24,18 @@ class ImageHeroPositions { static int _countMarkdownImages(String content) { final document = md.Document( extensionSet: md.ExtensionSet.gitHubWeb, - blockSyntaxes: [ImageBlockSyntax()], + blockSyntaxes: markdownBlockSyntaxes(), ); - // Inline images become alt text in MarkdownViewer, so only top-level - // standalone images consume a Hero position. + // Inline images become alt text in MarkdownViewer, and nested standalone + // images render without an automatic tag, so only top-level standalone + // images consume a Hero position. return document .parseLines(content.split('\n')) .whereType() - .where((element) => element.tag == 'img') + .where( + (element) => + element.attributes['data-superdeck-block-image'] == 'true', + ) .length; } diff --git a/packages/superdeck/test/src/markdown/hero_transition_test.dart b/packages/superdeck/test/src/markdown/hero_transition_test.dart index f63183ed7..681a34f9b 100644 --- a/packages/superdeck/test/src/markdown/hero_transition_test.dart +++ b/packages/superdeck/test/src/markdown/hero_transition_test.dart @@ -348,6 +348,59 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('nested standalone images take no automatic Hero position', ( + tester, + ) async { + _setSlideViewport(tester); + final from = Slide( + key: 'nested-images-from', + sections: [ + SectionBlock([ + ContentBlock('> ![Quoted](https://example.com/a.png)'), + ContentBlock('> [!NOTE]\n> ![Alerted](https://example.com/b.png)'), + ContentBlock('- ![Listed](https://example.com/c.png)'), + ContentBlock('![Top](https://example.com/d.png)'), + ]), + ], + ); + final to = Slide( + key: 'nested-images-to', + sections: [ + SectionBlock([ContentBlock('![Next]($_imageUri)')]), + ], + ); + + await _pumpHeroRoutes(tester, from: from, to: to); + expect(find.byType(Hero), findsOneWidget); + expect(_imageHeroTags(tester), {'superdeck:image:0'}); + // List items render only their text, so the listed image never mounts. + expect(find.byType(CachedImage), findsNWidgets(3)); + + _navigateToNextSlide(tester); + await tester.pump(); + await tester.pump(_transitionDuration ~/ 2); + expect(tester.takeException(), isNull); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('explicit image Hero tags still apply inside a blockquote', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'quoted-explicit-tag', + sections: [ + SectionBlock([ContentBlock('> ![Quoted]($_imageUri) {.chosen}')]), + ], + ), + ); + + expect(_imageHeroTags(tester), {'chosen'}); + }); + testWidgets('image flight follows the rendered Hero frame', (tester) async { _setSlideViewport(tester); final from = Slide( From b925d7ccc724a45f03c87021dbf5a8ed5804e758 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 25 Sep 2026 14:55:10 -0400 Subject: [PATCH 3/4] feat(superdeck): add DeckOptions.animateImages switch Automatic image Hero transitions stay on by default. Setting `DeckOptions(animateImages: false)` stops untagged standalone Markdown images and `@image` blocks from taking automatic Hero tags, while explicit Markdown Hero tags keep animating. The option reaches the renderers through SlideConfiguration, like `debug`. --- .../lib/src/builtins/image_widget.dart | 4 +- .../superdeck/lib/src/deck/deck_options.dart | 13 +++++- .../lib/src/deck/slide_configuration.dart | 11 ++++- .../src/deck/slide_configuration_builder.dart | 1 + .../builders/image_element_builder.dart | 7 ++-- .../test/src/deck/deck_options_test.dart | 16 +++++++ .../slide_configuration_builder_test.dart | 13 ++++++ .../src/markdown/hero_transition_test.dart | 42 +++++++++++++++++++ 8 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/superdeck/lib/src/builtins/image_widget.dart b/packages/superdeck/lib/src/builtins/image_widget.dart index 0d21328b0..4569a726a 100644 --- a/packages/superdeck/lib/src/builtins/image_widget.dart +++ b/packages/superdeck/lib/src/builtins/image_widget.dart @@ -152,7 +152,9 @@ class ImageWidget extends StatelessWidget { Size blockSize, ) { final slide = InheritedData.maybeOf(context); - if (slide == null || slide.isStaticRendering) return image; + if (slide == null || slide.isStaticRendering || !slide.animateImages) { + return image; + } final block = BlockConfiguration.of(context); final size = Size( diff --git a/packages/superdeck/lib/src/deck/deck_options.dart b/packages/superdeck/lib/src/deck/deck_options.dart index f8327dbbb..8e441501d 100644 --- a/packages/superdeck/lib/src/deck/deck_options.dart +++ b/packages/superdeck/lib/src/deck/deck_options.dart @@ -18,6 +18,11 @@ class DeckOptions { final SlideParts parts; final bool debug; + /// Pairs standalone Markdown images and `@image` blocks by order across + /// consecutive slides so they animate through Hero transitions. Explicit + /// Markdown Hero tags are unaffected. + final bool animateImages; + /// Named slide templates that bundle chrome + style systems. final Map templates; @@ -33,6 +38,7 @@ class DeckOptions { Map widgets = const {}, this.parts = const SlideParts(), this.debug = false, + this.animateImages = true, Map templates = const {}, this.defaultTemplate, }) : styles = Map.unmodifiable(styles), @@ -45,6 +51,7 @@ class DeckOptions { Map? widgets, SlideParts? parts, bool? debug, + bool? animateImages, Map? templates, Object? defaultTemplate = _undefined, }) { @@ -56,6 +63,7 @@ class DeckOptions { widgets: widgets ?? this.widgets, parts: parts ?? this.parts, debug: debug ?? this.debug, + animateImages: animateImages ?? this.animateImages, templates: templates ?? this.templates, defaultTemplate: identical(defaultTemplate, _undefined) ? this.defaultTemplate @@ -73,6 +81,7 @@ class DeckOptions { _widgetsEquality.equals(widgets, other.widgets) && parts == other.parts && debug == other.debug && + animateImages == other.animateImages && _templatesEquality.equals(templates, other.templates) && defaultTemplate == other.defaultTemplate; } @@ -84,6 +93,7 @@ class DeckOptions { _widgetsEquality.hash(widgets), parts, debug, + animateImages, _templatesEquality.hash(templates), defaultTemplate, ); @@ -92,6 +102,7 @@ class DeckOptions { String toString() { return 'DeckOptions(baseStyle: $baseStyle, styles: $styles, ' 'widgets: $widgets, parts: $parts, debug: $debug, ' - 'templates: $templates, defaultTemplate: $defaultTemplate)'; + 'animateImages: $animateImages, templates: $templates, ' + 'defaultTemplate: $defaultTemplate)'; } } diff --git a/packages/superdeck/lib/src/deck/slide_configuration.dart b/packages/superdeck/lib/src/deck/slide_configuration.dart index f861ae578..f9f62c105 100644 --- a/packages/superdeck/lib/src/deck/slide_configuration.dart +++ b/packages/superdeck/lib/src/deck/slide_configuration.dart @@ -17,6 +17,9 @@ class SlideConfiguration { final SlideStyler style; final Slide slide; final bool debug; + + /// Whether untagged standalone images take automatic Hero positions. + final bool animateImages; final SlideParts? parts; final Map widgets; // Runtime thumbnail cache key (for example: thumbnail_intro.png). @@ -34,6 +37,7 @@ class SlideConfiguration { required this.style, required this.slide, this.debug = false, + this.animateImages = true, this.parts, required this.thumbnailKey, this.widgets = const {}, @@ -46,6 +50,7 @@ class SlideConfiguration { SlideStyler? style, Slide? slide, bool? debug, + bool? animateImages, Object? parts = _undefined, Map? widgets, String? thumbnailKey, @@ -57,6 +62,7 @@ class SlideConfiguration { style: style ?? this.style, slide: slide ?? this.slide, debug: debug ?? this.debug, + animateImages: animateImages ?? this.animateImages, parts: identical(parts, _undefined) ? this.parts : parts as SlideParts?, widgets: widgets ?? this.widgets, thumbnailKey: thumbnailKey ?? this.thumbnailKey, @@ -90,6 +96,7 @@ class SlideConfiguration { style == other.style && slide == other.slide && debug == other.debug && + animateImages == other.animateImages && parts == other.parts && thumbnailKey == other.thumbnailKey && widgets == other.widgets && @@ -102,6 +109,7 @@ class SlideConfiguration { style, slide, debug, + animateImages, parts, thumbnailKey, widgets, @@ -112,7 +120,8 @@ class SlideConfiguration { @override String toString() { return 'SlideConfiguration(slideIndex: $slideIndex, style: $style, ' - 'slide: $slide, debug: $debug, parts: $parts, widgets: $widgets, ' + 'slide: $slide, debug: $debug, animateImages: $animateImages, ' + 'parts: $parts, widgets: $widgets, ' 'thumbnailKey: $thumbnailKey, isStaticRendering: $isStaticRendering, ' 'assetCacheStore: $assetCacheStore)'; } diff --git a/packages/superdeck/lib/src/deck/slide_configuration_builder.dart b/packages/superdeck/lib/src/deck/slide_configuration_builder.dart index 5d4bc7156..d8ca2a950 100644 --- a/packages/superdeck/lib/src/deck/slide_configuration_builder.dart +++ b/packages/superdeck/lib/src/deck/slide_configuration_builder.dart @@ -68,6 +68,7 @@ class SlideConfigurationBuilder { thumbnailKey: buildThumbnailKey(slide.key), parts: resolution.parts, debug: options.debug, + animateImages: options.animateImages, assetCacheStore: assetCacheStore, ); } diff --git a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart index 51a61b9b4..27b3a5bcb 100644 --- a/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/image_element_builder.dart @@ -68,12 +68,13 @@ class ImageElementBuilder extends MarkdownElementBuilder return ErrorWidgets.simple('Invalid image source: ${e.toString()}'); } + final slide = InheritedData.maybeOf(context); final block = BlockConfiguration.of(context); final isStandalone = element.attributes['data-superdeck-block-image'] == 'true'; final heroTag = element.attributes['hero'] ?? - (isStandalone + (isStandalone && (slide?.animateImages ?? true) ? automaticImageHeroTag(block.imageHeroStart + _nextImageIndex) : null); if (isStandalone) _nextImageIndex++; @@ -83,9 +84,7 @@ class ImageElementBuilder extends MarkdownElementBuilder // A bare key (e.g. an AI-generated `slide-x-illustration.png`) is resolved // through the slide's asset cache when one is bound. - final assetCacheStore = InheritedData.maybeOf( - context, - )?.assetCacheStore; + final assetCacheStore = slide?.assetCacheStore; if (assetCacheStore != null && isBareAssetKey(uri)) { final image = ConstrainedBox( constraints: BoxConstraints.tight(totalSize), diff --git a/packages/superdeck/test/src/deck/deck_options_test.dart b/packages/superdeck/test/src/deck/deck_options_test.dart index 9b716ec88..010fbe61c 100644 --- a/packages/superdeck/test/src/deck/deck_options_test.dart +++ b/packages/superdeck/test/src/deck/deck_options_test.dart @@ -23,6 +23,22 @@ void main() { }); }); + group('animateImages', () { + test('defaults to true', () { + expect(DeckOptions().animateImages, isTrue); + }); + + test('copyWith round-trips and participates in equality', () { + final options = DeckOptions(); + final disabled = options.copyWith(animateImages: false); + + expect(disabled.animateImages, isFalse); + expect(disabled.copyWith().animateImages, isFalse); + expect(disabled.copyWith(animateImages: true), equals(options)); + expect(disabled, isNot(equals(options))); + }); + }); + group('unmodifiable collections', () { test( 'modifying original map after construction does not affect stored map', diff --git a/packages/superdeck/test/src/deck/slide_configuration_builder_test.dart b/packages/superdeck/test/src/deck/slide_configuration_builder_test.dart index 6f0703bcd..3b436ee92 100644 --- a/packages/superdeck/test/src/deck/slide_configuration_builder_test.dart +++ b/packages/superdeck/test/src/deck/slide_configuration_builder_test.dart @@ -164,5 +164,18 @@ void main() { expect(configs.first.thumbnailKey, 'thumbnail_cover.png'); }); + + test('animateImages carries the deck option to every slide', () { + final slides = [Slide(key: 'a'), Slide(key: 'b')]; + + final animated = builder.buildConfigurations(slides, DeckOptions()); + final still = builder.buildConfigurations( + slides, + DeckOptions(animateImages: false), + ); + + expect(animated.map((config) => config.animateImages), [true, true]); + expect(still.map((config) => config.animateImages), [false, false]); + }); }); } diff --git a/packages/superdeck/test/src/markdown/hero_transition_test.dart b/packages/superdeck/test/src/markdown/hero_transition_test.dart index 681a34f9b..a254f43e6 100644 --- a/packages/superdeck/test/src/markdown/hero_transition_test.dart +++ b/packages/superdeck/test/src/markdown/hero_transition_test.dart @@ -401,6 +401,48 @@ void main() { expect(_imageHeroTags(tester), {'chosen'}); }); + testWidgets('animateImages false keeps only explicit image Heroes', ( + tester, + ) async { + SlideConfiguration still(Slide slide) => + SlideTestHarness.createConfiguration( + slide, + ).copyWith(animateImages: false); + + await SlideTestHarness.pumpConfiguration( + tester, + still( + Slide( + key: 'still-images', + sections: [ + SectionBlock([ + ContentBlock('![Markdown]($_imageUri)'), + WidgetBlock( + name: 'image', + args: {'src': 'https://example.com/widget.png'}, + ), + ]), + ], + ), + ), + ); + expect(find.byType(Hero), findsNothing); + expect(find.byType(CachedImage), findsNWidgets(2)); + + await SlideTestHarness.pumpConfiguration( + tester, + still( + Slide( + key: 'still-explicit-image', + sections: [ + SectionBlock([ContentBlock('![Chosen]($_imageUri) {.chosen}')]), + ], + ), + ), + ); + expect(_imageHeroTags(tester), {'chosen'}); + }); + testWidgets('image flight follows the rendered Hero frame', (tester) async { _setSlideViewport(tester); final from = Slide( From 871e2154ab7b53a7e92a148f6636ddd0742f88f9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 25 Sep 2026 14:55:13 -0400 Subject: [PATCH 4/4] docs: describe automatic image transitions and the animateImages option --- .../references/authoring.md | 2 +- docs/reference/deck-options.mdx | 19 +++++++++++++++++++ docs/reference/markdown-syntax.mdx | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.agents/skills/superdeck-presentations/references/authoring.md b/.agents/skills/superdeck-presentations/references/authoring.md index 0abab97bc..a167dc087 100644 --- a/.agents/skills/superdeck-presentations/references/authoring.md +++ b/.agents/skills/superdeck-presentations/references/authoring.md @@ -443,7 +443,7 @@ The first valid class name is used as the Hero tag. The class does not need a `h Use the same tag on the element that should animate from one slide to the next. For example, a heading marked `{.heading}` on slide 1 will transition to the heading marked `{.heading}` on slide 2. -Images also transition automatically by their order on consecutive slides, including standalone Markdown images and `@image` blocks. The first image flies to the first image, the second to the second, and so on, even when the layout or source changes. An explicit Markdown image tag overrides its automatic match when a particular image needs a stable identity. +Images also transition automatically by their order on consecutive slides, including standalone Markdown images and `@image` blocks. The first image flies to the first image, the second to the second, and so on, even when the layout or source changes. An explicit Markdown image tag overrides its automatic match when a particular image needs a stable identity. Images nested in blockquotes, alerts, or list items do not take part, and `DeckOptions(animateImages: false)` turns automatic image transitions off for the whole deck. Do not duplicate the same Hero tag on one slide. Flutter Hero transitions require one source/destination element for each tag in a route; if a slide contains two elements marked `{.heading}`, the transition is ambiguous and can fail. Use distinct tags such as `{.heading}` and `{.subheading}` for multiple animated text elements on the same slide. diff --git a/docs/reference/deck-options.mdx b/docs/reference/deck-options.mdx index 638d36fc2..4a35f9b13 100644 --- a/docs/reference/deck-options.mdx +++ b/docs/reference/deck-options.mdx @@ -16,6 +16,7 @@ DeckOptions({ Map widgets = const {}, SlideParts parts = const SlideParts(), bool debug = false, + bool animateImages = true, Map templates = const {}, SlideTemplate? defaultTemplate, }); @@ -211,6 +212,21 @@ DeckOptions( ) ``` +### `animateImages` +**Type:** `bool` | **Default:** `true` + +Pairs standalone Markdown images and `@image` blocks by order across +consecutive slides so they animate through Hero transitions. Images nested in +blockquotes, alerts, or list items do not take part. Set it to `false` to turn +automatic image transitions off for the whole deck. Explicit Markdown Hero tags, +such as `![Diagram](assets/roadmap.png) {.hero-visual}`, are unaffected. + +```dart +DeckOptions( + animateImages: false, +) +``` + ### `templates` **Type:** `Map` | **Default:** `{}` @@ -300,6 +316,9 @@ class MyApp extends StatelessWidget { // Debug mode for development debug: kDebugMode, + + // Automatic image transitions between slides (on by default) + animateImages: true, ), ); } diff --git a/docs/reference/markdown-syntax.mdx b/docs/reference/markdown-syntax.mdx index edb70a281..a7129769d 100644 --- a/docs/reference/markdown-syntax.mdx +++ b/docs/reference/markdown-syntax.mdx @@ -227,6 +227,6 @@ Use Markdown class markers to animate matching elements between slides. The firs Use the same tag for the element that should transition from one slide to the next. For example, one `{.heading}` on slide A can animate to one `{.heading}` on slide B. -Standalone Markdown images and `@image` blocks transition automatically by image order: the first image on one slide moves to the first on the next slide, the second to the second, and so on. This also blends different image sources. Use an explicit Markdown class marker when an image needs to match by a named identity instead of its position. Images without a partner on the adjacent slide render normally. +Standalone Markdown images and `@image` blocks transition automatically by image order: the first image on one slide moves to the first on the next slide, the second to the second, and so on. This also blends different image sources. Use an explicit Markdown class marker when an image needs to match by a named identity instead of its position. Images without a partner on the adjacent slide render normally. Images nested in blockquotes, alerts, or list items do not take part, and `DeckOptions(animateImages: false)` turns automatic image transitions off for the whole deck. Do not use the same tag twice on one slide. Each Hero tag must identify one element per slide, so use separate tags like `{.heading}` and `{.subheading}` when multiple elements should animate.