import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../app_progress_indicator.dart'; import 'pm_document_view.dart'; import 'pm_node.dart'; /// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds. /// Parsing decodes base64 images; doing it in build() would re-decode them on /// every rebuild — call sites should use this instead of PmNode.fromJson. /// /// Images are precached before a document is presented, so the reader never /// sees images pop in and text reflow downwards. Until the first document is /// ready a spinner shows; on updates the previous document stays visible until /// the new one is fully precached (no spinner flash on background refreshes). /// A timeout keeps a dead network image from blocking the swap forever (the /// image then renders with its broken-image fallback). class PmJsonView extends StatefulWidget { final Map json; final void Function(String href)? onLinkTap; const PmJsonView({required this.json, this.onLinkTap, super.key}); static const Duration precacheTimeout = Duration(seconds: 8); @override State createState() => _PmJsonViewState(); } class _PmJsonViewState extends State { PmNode? _shown; PmNode? _pending; List _pendingProviders = const []; bool _precacheStarted = false; int _generation = 0; @override void initState() { super.initState(); _parse(); } @override void didUpdateWidget(PmJsonView oldWidget) { super.didUpdateWidget(oldWidget); if (identical(oldWidget.json, widget.json)) return; // Background refreshes deliver a new map instance with identical content; // re-parsing would re-decode every base64 image for a no-op swap. if (const DeepCollectionEquality().equals(oldWidget.json, widget.json)) { return; } _parse(); _precache(); } @override void didChangeDependencies() { super.didChangeDependencies(); _precache(); } void _parse() { final doc = PmNode.fromJson(widget.json); final providers = []; _collectProviders(doc, providers); _generation++; _precacheStarted = false; if (providers.isEmpty) { _shown = doc; _pending = null; _pendingProviders = const []; } else { _pending = doc; _pendingProviders = providers; } } void _precache() { final pending = _pending; if (pending == null || _precacheStarted) return; _precacheStarted = true; final generation = _generation; Future.wait([ for (final provider in _pendingProviders) precacheImage(provider, context, onError: (_, _) {}), ]) .timeout(PmJsonView.precacheTimeout, onTimeout: () => const []) .whenComplete(() { if (mounted && generation == _generation) { setState(() { _shown = pending; _pending = null; _pendingProviders = const []; }); } }); } void _collectProviders(PmNode node, List out) { if (node is PmImage) { final bytes = node.bytes; if (bytes != null) { out.add(MemoryImage(bytes)); } else if (node.src.startsWith('http')) { out.add(CachedNetworkImageProvider(node.src)); } } for (final child in node.children) { _collectProviders(child, out); } } @override Widget build(BuildContext context) { final shown = _shown; if (shown == null) { return const SizedBox( height: 160, child: Center(child: AppProgressIndicator.large()), ); } return PmDocumentView(doc: shown, onLinkTap: widget.onLinkTap); } }