Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/reference/deck-options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ DeckOptions({
Map<String, WidgetFactory> widgets = const {},
SlideParts parts = const SlideParts(),
bool debug = false,
bool animateImages = true,
Map<String, SlideTemplate> templates = const {},
SlideTemplate? defaultTemplate,
});
Expand Down Expand Up @@ -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<String, SlideTemplate>` | **Default:** `{}`

Expand Down Expand Up @@ -300,6 +316,9 @@ class MyApp extends StatelessWidget {

// Debug mode for development
debug: kDebugMode,

// Automatic image transitions between slides (on by default)
animateImages: true,
),
);
}
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/markdown-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 44 additions & 2 deletions packages/superdeck/lib/src/builtins/image_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SlideConfiguration>(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<ImageElement>(
data: ImageElement(
spec: spec,
uri: _data.src,
size: size,
flightImage: image,
),
child: buildElementHero<ImageElement>(
tag: automaticImageHeroTag(block.imageHeroStart),
child: image,
buildFlight: buildImageHeroFlight,
),
);
}

@override
Widget build(BuildContext context) {
final data = BlockConfiguration.of(context);
Expand Down Expand Up @@ -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(
Expand All @@ -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,
),
);
}
}
Expand Down
13 changes: 12 additions & 1 deletion packages/superdeck/lib/src/deck/deck_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SlideTemplate> templates;

Expand All @@ -33,6 +38,7 @@ class DeckOptions {
Map<String, WidgetFactory> widgets = const <String, WidgetFactory>{},
this.parts = const SlideParts(),
this.debug = false,
this.animateImages = true,
Map<String, SlideTemplate> templates = const <String, SlideTemplate>{},
this.defaultTemplate,
}) : styles = Map.unmodifiable(styles),
Expand All @@ -45,6 +51,7 @@ class DeckOptions {
Map<String, WidgetFactory>? widgets,
SlideParts? parts,
bool? debug,
bool? animateImages,
Map<String, SlideTemplate>? templates,
Object? defaultTemplate = _undefined,
}) {
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -84,6 +93,7 @@ class DeckOptions {
_widgetsEquality.hash(widgets),
parts,
debug,
animateImages,
_templatesEquality.hash(templates),
defaultTemplate,
);
Expand All @@ -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)';
}
}
11 changes: 10 additions & 1 deletion packages/superdeck/lib/src/deck/slide_configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, WidgetFactory> widgets;
// Runtime thumbnail cache key (for example: thumbnail_intro.png).
Expand All @@ -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 {},
Expand All @@ -46,6 +50,7 @@ class SlideConfiguration {
SlideStyler? style,
Slide? slide,
bool? debug,
bool? animateImages,
Object? parts = _undefined,
Map<String, WidgetFactory>? widgets,
String? thumbnailKey,
Expand All @@ -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,
Expand Down Expand Up @@ -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 &&
Expand All @@ -102,6 +109,7 @@ class SlideConfiguration {
style,
slide,
debug,
animateImages,
parts,
thumbnailKey,
widgets,
Expand All @@ -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)';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class SlideConfigurationBuilder {
thumbnailKey: buildThumbnailKey(slide.key),
parts: resolution.parts,
debug: options.debug,
animateImages: options.animateImages,
assetCacheStore: assetCacheStore,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +26,11 @@ class ImageElementBuilder extends MarkdownElementBuilder
with MarkdownHeroMixin {
final StyleSpec<ImageSpec> 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
Expand Down Expand Up @@ -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<SlideConfiguration>(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<SlideConfiguration>(
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,
Expand All @@ -83,11 +96,23 @@ class ImageElementBuilder extends MarkdownElementBuilder
styleSpec: styleSpec,
),
);
return applyHeroIfNeeded<ImageElement>(
context: context,
child: image,
heroTag: heroTag,
heroData: ImageElement(
spec: styleSpec.spec,
uri: uri,
size: totalSize,
flightImage: image,
),
buildFlight: buildImageHeroFlight,
);
}

return StyleSpecBuilder<ImageSpec>(
builder: (builderContext, spec) {
Widget imageWidget = ConstrainedBox(
final imageWidget = ConstrainedBox(
constraints: BoxConstraints.tight(totalSize),
child: CachedImage(uri: uri, styleSpec: styleSpec),
);
Expand All @@ -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,
Expand Down
Loading
Loading