implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.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<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<ImageProvider> _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 = <ImageProvider>[];
|
||||
_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<ImageProvider> 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: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return PmDocumentView(doc: shown, onLinkTap: widget.onLinkTap);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user