diff --git a/.agents/skills/superdeck-presentations/references/authoring.md b/.agents/skills/superdeck-presentations/references/authoring.md index 177372a8..a167dc08 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. 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. 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/deck-options.mdx b/docs/reference/deck-options.mdx index 638d36fc..4a35f9b1 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 71296d78..a7129769 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. 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. diff --git a/packages/superdeck/lib/src/builtins/image_widget.dart b/packages/superdeck/lib/src/builtins/image_widget.dart index 0621a949..4569a726 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,37 @@ 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 || !slide.animateImages) { + 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 +217,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 +237,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/deck/deck_options.dart b/packages/superdeck/lib/src/deck/deck_options.dart index f8327dbb..8e441501 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 f861ae57..f9f62c10 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 5d4bc715..d8ca2a95 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/alert_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/alert_element_builder.dart index 7ab99f5d..28fc6abe 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 22d3de14..27b3a5bc 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'; @@ -24,6 +26,11 @@ 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())]); @override @@ -61,19 +68,25 @@ class ImageElementBuilder extends MarkdownElementBuilder return ErrorWidgets.simple('Invalid image source: ${e.toString()}'); } - final heroTag = element.attributes['hero']; + 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 && (slide?.animateImages ?? true) + ? 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). - final assetCacheStore = InheritedData.maybeOf( - context, - )?.assetCacheStore; + // through the slide's asset cache when one is bound. + final assetCacheStore = slide?.assetCacheStore; if (assetCacheStore != null && isBareAssetKey(uri)) { - return ConstrainedBox( + final image = ConstrainedBox( constraints: BoxConstraints.tight(totalSize), child: ResolvedAssetImage( assetKey: uri.path, @@ -83,11 +96,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 +122,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 000540d9..1ccb9874 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; @@ -51,6 +59,12 @@ class ImageBlockSyntax extends md.BlockSyntax { ..attributes['src'] = src ..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 f46315e9..7a0fc3c3 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/block_provider.dart b/packages/superdeck/lib/src/rendering/blocks/block_provider.dart index 789ed3d9..8b591279 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 bc8bdff9..f54eac00 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 00000000..4e068374 --- /dev/null +++ b/packages/superdeck/lib/src/rendering/blocks/image_hero_positions.dart @@ -0,0 +1,47 @@ +import 'package:markdown/markdown.dart' as md; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../markdown/markdown_element_builders_registry.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: markdownBlockSyntaxes(), + ); + // 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.attributes['data-superdeck-block-image'] == 'true', + ) + .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 2325d614..4b5f437a 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 28ab5095..e4d18ac9 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 00000000..782ca567 --- /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/deck/deck_options_test.dart b/packages/superdeck/test/src/deck/deck_options_test.dart index 9b716ec8..010fbe61 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 6f0703bc..3b436ee9 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 3a1d838e..a254f43e 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,295 @@ 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('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('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( + 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 +964,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;