Files
Client/lib/widget/prosemirror/pm_json_view.dart
T

120 lines
3.5 KiB
Dart

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../app_progress_indicator.dart';
import 'pm_document_view.dart';
import 'pm_image_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<String, dynamic> 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<PmJsonView> createState() => _PmJsonViewState();
}
class _PmJsonViewState extends State<PmJsonView> {
PmNode? _shown;
PmNode? _pending;
List<PmImage> _pendingImages = 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 images = <PmImage>[];
_collectImages(doc, images);
_generation++;
_precacheStarted = false;
if (images.isEmpty) {
_shown = doc;
_pending = null;
_pendingImages = const [];
} else {
_pending = doc;
_pendingImages = images;
}
}
void _precache() {
final pending = _pending;
if (pending == null || _precacheStarted) return;
_precacheStarted = true;
final generation = _generation;
Future.wait([
for (final image in _pendingImages)
if (PmImageView.inlineProvider(context, image) case final provider?)
precacheImage(provider, context, onError: (_, _) {}),
])
.timeout(PmJsonView.precacheTimeout, onTimeout: () => const [])
.whenComplete(() {
if (mounted && generation == _generation) {
setState(() {
_shown = pending;
_pending = null;
_pendingImages = const [];
});
}
});
}
void _collectImages(PmNode node, List<PmImage> out) {
if (node is PmImage) out.add(node);
for (final child in node.children) {
_collectImages(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);
}
}