diff --git a/packages/sanity/flutter_sanity_portable_text/lib/flutter_sanity_portable_text.dart b/packages/sanity/flutter_sanity_portable_text/lib/flutter_sanity_portable_text.dart index 478f41ac..1729659f 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/flutter_sanity_portable_text.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/flutter_sanity_portable_text.dart @@ -10,6 +10,7 @@ /// - Custom mark definitions /// - Custom text mark recognizers /// - Shared config across all Portable Text Widgets +/// - Per-subtree config overrides via [PortableTextTheme] / [PortableTextStyleOverride] /// library; @@ -17,4 +18,5 @@ export 'model/markdef_descriptor.dart'; export 'model/text_block.dart'; export 'ui/portable_text_block.dart'; export 'ui/portable_text_config.dart'; +export 'ui/portable_text_theme.dart'; export 'ui/portable_text_widget.dart'; diff --git a/packages/sanity/flutter_sanity_portable_text/lib/model/text_block.dart b/packages/sanity/flutter_sanity_portable_text/lib/model/text_block.dart index 89c22850..a7c42af1 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/model/text_block.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/model/text_block.dart @@ -94,6 +94,11 @@ class Span { _$SpanFromJson(json); } +/// Resolves custom mark deserializers from [PortableTextConfig.shared]. +/// +/// This runs at JSON-parse time with no [BuildContext], so it must read the context-free +/// [PortableTextConfig.shared] registry rather than a per-subtree config. Mark *styling* is +/// still resolved per subtree at render time via `PortableTextConfig.of(context)`. List _markDefsFromJson(final List json) { final markDefs = PortableTextConfig.shared.markDefs; diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart index 5ba410bf..0555865a 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart @@ -18,7 +18,7 @@ class PortableTextBlock extends StatelessWidget { @override Widget build(final BuildContext context) { - final config = PortableTextConfig.shared; + final config = PortableTextConfig.of(context); final spans = model.children .map((final span) => _buildInlineSpan(span, Theme.of(context), context)) @@ -52,7 +52,7 @@ class PortableTextBlock extends StatelessWidget { final ThemeData theme, final BuildContext context, ) { - final config = PortableTextConfig.shared; + final config = PortableTextConfig.of(context); // Step 1: Start with the base style final baseStyle = config.baseStyle(context) ?? diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart index 44bd00e7..84bbf4ec 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart @@ -27,7 +27,8 @@ typedef BulletRenderer = InlineSpan Function(BuildContext, TextBlockItem); /// configuration can be customized to match the visual design of the app. The default /// configuration is based on the Material Design guidelines. /// -/// Note that the configuration is shared across all instances of the [PortableText] widget. +/// A single global default lives in [shared]; individual subtrees can override the rendering +/// configuration by wrapping them in a [PortableTextTheme] (resolved via [of]). final class PortableTextConfig { /// The styles used to render the Portable Text content. The keys are the style names used /// in the Portable Text content, such as "h1", "h2", "blockquote", etc. The default styles @@ -63,15 +64,72 @@ final class PortableTextConfig { /// The base style used for rendering the Portable Text content. The default value is the bodyMedium style from the theme. TextStyle? Function(BuildContext) baseStyle = defaultBaseStyle; - /// The shared instance of the PortableTextConfig. This instance is used by all [PortableText] widgets - /// in the application. You can customize the configuration by calling the [apply] method. - static final PortableTextConfig shared = PortableTextConfig._(); + /// The default/root configuration, used by [PortableText] widgets when no [PortableTextTheme] + /// ancestor provides one (see [of]). It is also the context-free registry consulted at + /// JSON-parse time for custom mark deserializers (see `_markDefsFromJson`). You can customize + /// it by calling the [apply] method. + static final PortableTextConfig shared = PortableTextConfig(); /// The bullet renderer used to render the bullet for list items. The default value is a simple bullet renderer /// that handles the default bullet types: number, square, and circle. BulletRenderer bulletRenderer = defaultBulletRenderer; - PortableTextConfig._(); + PortableTextConfig(); + + /// Returns the nearest [PortableTextConfig] supplied by a [PortableTextTheme] ancestor, + /// falling back to [shared] when none is present. Mirrors `Theme.of`. + static PortableTextConfig of(final BuildContext context) => + PortableTextTheme.maybeOf(context) ?? shared; + + /// Returns a copy of this config with the given fields overridden. + /// + /// The map fields ([styles], [blocks], [blockContainers], [markDefs]) are MERGED — provided + /// keys win, the rest are kept — and scalar fields are replaced when provided. This + /// intentionally differs from `ThemeData.copyWith` (which replaces whole fields) because + /// these maps are additive registries, so the common case is overriding a single key while + /// keeping the others. + /// + /// Note: overriding [markDefs] here affects mark *rendering/styling* only. It does not change + /// which custom deserializer runs at JSON-parse time — that always reads the context-free + /// [shared] registry (see `_markDefsFromJson`). + PortableTextConfig copyWith({ + final Map? styles, + final Map? blocks, + final Map? blockContainers, + final Map? markDefs, + final double? listIndent, + final EdgeInsets? itemPadding, + final TextStyle? Function(BuildContext)? baseStyle, + final BulletRenderer? bulletRenderer, + }) { + final config = PortableTextConfig(); + config.styles + ..clear() + ..addAll(this.styles); + config.blocks + ..clear() + ..addAll(this.blocks); + config.blockContainers + ..clear() + ..addAll(this.blockContainers); + config.markDefs + ..clear() + ..addAll(this.markDefs); + config.listIndent = this.listIndent; + config.itemPadding = this.itemPadding; + config.baseStyle = this.baseStyle; + config.bulletRenderer = this.bulletRenderer; + + if (styles != null) config.styles.addAll(styles); + if (blocks != null) config.blocks.addAll(blocks); + if (blockContainers != null) config.blockContainers.addAll(blockContainers); + if (markDefs != null) config.markDefs.addAll(markDefs); + if (listIndent != null) config.listIndent = listIndent; + if (itemPadding != null) config.itemPadding = itemPadding; + if (baseStyle != null) config.baseStyle = baseStyle; + if (bulletRenderer != null) config.bulletRenderer = bulletRenderer; + return config; + } /// Applies the custom configuration to the shared instance of the [PortableTextConfig]. void apply({ @@ -131,7 +189,7 @@ final class PortableTextConfig { static const defaultItemPadding = EdgeInsets.only(bottom: 8); static BulletRenderer defaultBulletRenderer = (final BuildContext context, final TextBlockItem model) { - final textStyle = PortableTextConfig.shared.baseStyle(context); + final textStyle = PortableTextConfig.of(context).baseStyle(context); switch (model.listItem) { case ListItemType.number: diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_theme.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_theme.dart new file mode 100644 index 00000000..4bb559a8 --- /dev/null +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_theme.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../flutter_sanity_portable_text.dart'; + +/// An [InheritedWidget] that supplies a [PortableTextConfig] to its subtree. +/// +/// Mirrors Flutter's `Theme` / `Theme.of`: descendant Portable Text widgets resolve their +/// config via [PortableTextConfig.of], which returns the nearest [PortableTextTheme]'s +/// [config] or falls back to [PortableTextConfig.shared]. Wrap a subtree to render it with a +/// different configuration without mutating the global [PortableTextConfig.shared]. +class PortableTextTheme extends InheritedWidget { + /// The configuration applied to descendant Portable Text widgets. + final PortableTextConfig config; + + const PortableTextTheme({ + super.key, + required this.config, + required super.child, + }); + + /// The [PortableTextConfig] of the nearest [PortableTextTheme] ancestor, or `null` if none. + static PortableTextConfig? maybeOf(final BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.config; + + @override + bool updateShouldNotify(final PortableTextTheme oldWidget) => + config != oldWidget.config; +} + +/// Restyles descendant Portable Text by transforming the *inherited* text style for each +/// named style (e.g. `'normal'`, `'h2'`). +/// +/// Each entry receives the resolved style produced by the ambient config's builder and +/// returns the adjusted style, so callers express only the delta: +/// +/// ```dart +/// PortableTextStyleOverride( +/// styles: {'normal': (s) => s.copyWith(fontSize: (s.fontSize ?? 16) - 2)}, +/// child: ..., +/// ) +/// ``` +/// +/// The inherited builder is captured internally, so referencing the same style name does +/// not recurse. Everything else (blocks, marks, spacing, parsing) is inherited unchanged. +class PortableTextStyleOverride extends StatefulWidget { + /// Per-style transforms applied on top of the inherited resolved style. Keys are style + /// names (`'normal'`, `'h2'`, …); a key that the ambient config does not define is ignored. + final Map styles; + + /// The subtree whose Portable Text should be restyled. + final Widget child; + + const PortableTextStyleOverride({ + super.key, + required this.styles, + required this.child, + }); + + @override + State createState() => + _PortableTextStyleOverrideState(); +} + +class _PortableTextStyleOverrideState extends State { + PortableTextConfig? _base; + Map? _styles; + PortableTextConfig? _derived; + + @override + Widget build(final BuildContext context) { + final base = PortableTextConfig.of(context); + + // Recompute (and mint a new config) only when the inherited config or the + // override map actually changes. Otherwise the derived config stays + // identity-stable, so [PortableTextTheme.updateShouldNotify] returns false + // and descendants don't rebuild on every ancestor rebuild. + if (!identical(base, _base) || !identical(widget.styles, _styles)) { + _base = base; + _styles = widget.styles; + + final wrapped = { + for (final entry in widget.styles.entries) + if (base.styles[entry.key] case final builder?) + entry.key: (final ctx, final inherited) => + entry.value(builder(ctx, inherited)), + }; + _derived = base.copyWith(styles: wrapped); + } + + return PortableTextTheme( + config: _derived!, + child: widget.child, + ); + } +} diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_widget.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_widget.dart index b381a7cc..1a70a837 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_widget.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_widget.dart @@ -45,6 +45,6 @@ Widget defaultListBuilder( itemCount: blocks.length, padding: EdgeInsets.zero, itemBuilder: (final context, final index) => - PortableTextConfig.shared.buildBlock(context, blocks[index]), + PortableTextConfig.of(context).buildBlock(context, blocks[index]), ); }