From 14b7473b339a0cddf4e97685eb42fd5019e65cee Mon Sep 17 00:00:00 2001 From: Ortes Date: Tue, 2 Jun 2026 16:18:01 -0500 Subject: [PATCH 1/2] feat(portable_text): per-subtree config via PortableTextTheme Add PortableTextConfig.of(context) + copyWith and a PortableTextTheme InheritedWidget (plus PortableTextStyleOverride) so a subtree can override the rendering config without mutating the global shared instance. Render paths resolve the config from context, falling back to shared. Mark deserialization stays on the context-free shared registry. --- .../lib/flutter_sanity_portable_text.dart | 2 + .../lib/model/text_block.dart | 5 ++ .../lib/ui/portable_text_block.dart | 4 +- .../lib/ui/portable_text_config.dart | 66 +++++++++++++++-- .../lib/ui/portable_text_theme.dart | 73 +++++++++++++++++++ .../lib/ui/portable_text_widget.dart | 2 +- 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_theme.dart 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..fb24c524 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,68 @@ 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. + 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 +185,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..818dc1d6 --- /dev/null +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_theme.dart @@ -0,0 +1,73 @@ +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 StatelessWidget { + /// 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 + Widget build(final BuildContext context) { + final base = PortableTextConfig.of(context); + final wrapped = { + for (final entry in styles.entries) + if (base.styles[entry.key] case final builder?) + entry.key: (final ctx, final inherited) => + entry.value(builder(ctx, inherited)), + }; + return PortableTextTheme( + config: base.copyWith(styles: wrapped), + child: 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]), ); } From 8232c007da0d002ca06e3c3c187e90942cc99873 Mon Sep 17 00:00:00 2001 From: Ortes Date: Wed, 3 Jun 2026 14:02:09 -0500 Subject: [PATCH 2/2] fix(portable_text): memoize derived config in PortableTextStyleOverride Creating a fresh PortableTextConfig on every build made PortableTextTheme.updateShouldNotify (identity comparison) always return true, forcing all descendant Portable Text to rebuild on any ancestor rebuild. Cache the derived config and recompute only when the inherited config or the override map changes, keeping it identity-stable. Also document that copyWith(markDefs:) affects rendering only, not parse-time deserializer selection. --- .../lib/ui/portable_text_config.dart | 4 ++ .../lib/ui/portable_text_theme.dart | 40 ++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) 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 fb24c524..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 @@ -88,6 +88,10 @@ final class PortableTextConfig { /// 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, 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 index 818dc1d6..4bb559a8 100644 --- 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 @@ -42,7 +42,7 @@ class PortableTextTheme extends InheritedWidget { /// /// 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 StatelessWidget { +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; @@ -56,18 +56,40 @@ class PortableTextStyleOverride extends StatelessWidget { 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); - final wrapped = { - for (final entry in styles.entries) - if (base.styles[entry.key] case final builder?) - entry.key: (final ctx, final inherited) => - entry.value(builder(ctx, inherited)), - }; + + // 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: base.copyWith(styles: wrapped), - child: child, + config: _derived!, + child: widget.child, ); } }