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,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theming/app_theme.dart';
|
||||
import 'pm_document_view.dart';
|
||||
import 'pm_node.dart';
|
||||
|
||||
/// Accent color + leading icon per callout variant, mirroring the web renderer.
|
||||
class _CalloutStyle {
|
||||
final Color accent;
|
||||
final IconData icon;
|
||||
|
||||
const _CalloutStyle(this.accent, this.icon);
|
||||
}
|
||||
|
||||
const _calloutStyles = <PmCalloutVariant, _CalloutStyle>{
|
||||
PmCalloutVariant.info: _CalloutStyle(Color(0xFF2563EB), Icons.info_outline),
|
||||
PmCalloutVariant.tip: _CalloutStyle(
|
||||
Color(0xFFA16207),
|
||||
Icons.lightbulb_outline,
|
||||
),
|
||||
PmCalloutVariant.success: _CalloutStyle(
|
||||
Color(0xFF16A34A),
|
||||
Icons.check_circle_outline,
|
||||
),
|
||||
PmCalloutVariant.warning: _CalloutStyle(
|
||||
Color(0xFFEA580C),
|
||||
Icons.warning_amber_outlined,
|
||||
),
|
||||
PmCalloutVariant.important: _CalloutStyle(
|
||||
Color(0xFFB91C1C),
|
||||
Icons.priority_high,
|
||||
),
|
||||
};
|
||||
|
||||
class PmCalloutView extends StatelessWidget {
|
||||
final PmCallout node;
|
||||
|
||||
const PmCalloutView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = _calloutStyles[node.variant] ?? _calloutStyles.values.first;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: style.accent.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(left: BorderSide(color: style.accent, width: 4)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(style.icon, color: style.accent, size: 20),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(child: pmBlocks(node.children)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theming/app_theme.dart';
|
||||
import 'pm_callout_view.dart';
|
||||
import 'pm_image_view.dart';
|
||||
import 'pm_node.dart';
|
||||
import 'pm_render_scope.dart';
|
||||
import 'pm_rich_text.dart';
|
||||
import 'pm_table_view.dart';
|
||||
|
||||
/// Read-only renderer for a parsed ProseMirror document.
|
||||
///
|
||||
/// Content is centered and width-capped for comfortable line lengths on
|
||||
/// tablets. All styling derives from `Theme.of(context)`; the only hard-coded
|
||||
/// colors are the callout accents.
|
||||
class PmDocumentView extends StatelessWidget {
|
||||
final PmNode doc;
|
||||
final void Function(String href)? onLinkTap;
|
||||
final double maxContentWidth;
|
||||
|
||||
const PmDocumentView({
|
||||
required this.doc,
|
||||
this.onLinkTap,
|
||||
this.maxContentWidth = 720,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PmRenderScope(
|
||||
onLinkTap: onLinkTap,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxContentWidth),
|
||||
child: PmNodeView(node: doc),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef PmNodeBuilder = Widget Function(PmNode node);
|
||||
|
||||
/// Dispatches a node to its registered widget, falling back to
|
||||
/// [FallbackNodeWidget] for anything unregistered (including [PmUnknown]).
|
||||
class PmNodeView extends StatelessWidget {
|
||||
final PmNode node;
|
||||
|
||||
const PmNodeView({required this.node, super.key});
|
||||
|
||||
static final Map<Type, PmNodeBuilder> registry = {
|
||||
PmParagraph: (n) => PmParagraphView(node: n as PmParagraph),
|
||||
PmHeading: (n) => PmHeadingView(node: n as PmHeading),
|
||||
PmBulletList: (n) => PmBulletListView(node: n as PmBulletList),
|
||||
PmOrderedList: (n) => PmOrderedListView(node: n as PmOrderedList),
|
||||
PmBlockquote: (n) => PmBlockquoteView(node: n as PmBlockquote),
|
||||
PmCodeBlock: (n) => PmCodeBlockView(node: n as PmCodeBlock),
|
||||
PmHorizontalRule: (n) => const PmHorizontalRuleView(),
|
||||
PmCallout: (n) => PmCalloutView(node: n as PmCallout),
|
||||
PmImage: (n) => PmImageView(node: n as PmImage),
|
||||
PmTable: (n) => PmTableView(node: n as PmTable),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builder = registry[node.runtimeType];
|
||||
if (builder != null) return builder(node);
|
||||
return FallbackNodeWidget(node: node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a node's children as a vertical block stack. Used for the document
|
||||
/// root, unknown nodes, and any container whose type has no dedicated widget.
|
||||
class FallbackNodeWidget extends StatelessWidget {
|
||||
final PmNode node;
|
||||
|
||||
const FallbackNodeWidget({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (node.children.isEmpty) return const SizedBox.shrink();
|
||||
return pmBlocks(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out block-level nodes in a column, evenly spaced.
|
||||
Widget pmBlocks(List<PmNode> nodes, {double gap = AppSpacing.sm}) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (i > 0) children.add(SizedBox(height: gap));
|
||||
children.add(PmNodeView(node: nodes[i]));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
class PmParagraphView extends StatelessWidget {
|
||||
final PmParagraph node;
|
||||
|
||||
const PmParagraphView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
|
||||
return PmRichText(
|
||||
inlines: node.children,
|
||||
baseStyle: base,
|
||||
textAlign: node.align ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PmHeadingView extends StatelessWidget {
|
||||
final PmHeading node;
|
||||
|
||||
const PmHeadingView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final base = switch (node.level) {
|
||||
1 => textTheme.headlineSmall,
|
||||
2 => textTheme.titleLarge,
|
||||
3 => textTheme.titleMedium,
|
||||
_ => textTheme.titleSmall,
|
||||
};
|
||||
return PmRichText(
|
||||
inlines: node.children,
|
||||
baseStyle: base ?? const TextStyle(),
|
||||
textAlign: node.align ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PmBulletListView extends StatelessWidget {
|
||||
final PmBulletList node;
|
||||
|
||||
const PmBulletListView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
_ListLayout(items: node.children, markerFor: (_) => '•');
|
||||
}
|
||||
|
||||
class PmOrderedListView extends StatelessWidget {
|
||||
final PmOrderedList node;
|
||||
|
||||
const PmOrderedListView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ListLayout(
|
||||
items: node.children,
|
||||
markerFor: (index) => '${node.start + index}.',
|
||||
);
|
||||
}
|
||||
|
||||
class _ListLayout extends StatelessWidget {
|
||||
final List<PmNode> items;
|
||||
final String Function(int index) markerFor;
|
||||
|
||||
const _ListLayout({required this.items, required this.markerFor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
|
||||
final rows = <Widget>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (i > 0) rows.add(const SizedBox(height: AppSpacing.xs));
|
||||
rows.add(
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
markerFor(i),
|
||||
textAlign: TextAlign.right,
|
||||
style: base,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(child: pmBlocks(items[i].children, gap: AppSpacing.xs)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PmBlockquoteView extends StatelessWidget {
|
||||
final PmBlockquote node;
|
||||
|
||||
const PmBlockquoteView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: theme.colorScheme.outlineVariant, width: 4),
|
||||
),
|
||||
),
|
||||
child: pmBlocks(node.children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PmCodeBlockView extends StatelessWidget {
|
||||
final PmCodeBlock node;
|
||||
|
||||
const PmCodeBlockView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final code = node.children.whereType<PmText>().map((t) => t.text).join();
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Text(
|
||||
code,
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PmHorizontalRuleView extends StatelessWidget {
|
||||
const PmHorizontalRuleView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Divider();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
import 'pm_node.dart';
|
||||
import 'pm_render_scope.dart';
|
||||
|
||||
class PmImageView extends StatelessWidget {
|
||||
final PmImage node;
|
||||
|
||||
const PmImageView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = _imageProvider();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
final targetWidth = _resolveWidth(node.width, available);
|
||||
|
||||
Widget image = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
||||
child: _imageWidget(context, provider),
|
||||
);
|
||||
|
||||
final href = node.href;
|
||||
if (href != null && href.isNotEmpty) {
|
||||
image = InkWell(
|
||||
onTap: () => PmRenderScope.maybeOf(context)?.onLinkTap?.call(href),
|
||||
child: image,
|
||||
);
|
||||
} else if (provider != null) {
|
||||
image = InkWell(
|
||||
onTap: () => _openFullscreen(context, provider),
|
||||
child: image,
|
||||
);
|
||||
}
|
||||
|
||||
return Align(alignment: _alignment(node.align), child: image);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ImageProvider? _imageProvider() {
|
||||
if (node.bytes != null) return MemoryImage(node.bytes!);
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImageProvider(node.src);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _imageWidget(BuildContext context, ImageProvider? provider) {
|
||||
if (node.bytes != null) {
|
||||
return Image.memory(
|
||||
node.bytes!,
|
||||
errorBuilder: (context, error, stack) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: node.src,
|
||||
errorWidget: (context, url, error) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
return _brokenImage(context);
|
||||
}
|
||||
|
||||
Widget _brokenImage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openFullscreen(BuildContext context, ImageProvider provider) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black,
|
||||
builder: (context) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: PhotoView(
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 3,
|
||||
imageProvider: provider,
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Alignment _alignment(String? align) {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return Alignment.center;
|
||||
case 'right':
|
||||
return Alignment.centerRight;
|
||||
default:
|
||||
return Alignment.centerLeft;
|
||||
}
|
||||
}
|
||||
|
||||
double? _resolveWidth(String? width, double available) {
|
||||
if (width == null) return null;
|
||||
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$').firstMatch(width);
|
||||
if (match == null) return null;
|
||||
final value = double.parse(match.group(1)!);
|
||||
final double resolved;
|
||||
switch (match.group(2)) {
|
||||
case '%':
|
||||
resolved = available * value / 100;
|
||||
case 'em':
|
||||
case 'rem':
|
||||
resolved = value * 16;
|
||||
default:
|
||||
resolved = value;
|
||||
}
|
||||
return resolved.clamp(1, available);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Read-only model for a ProseMirror / TipTap document node.
|
||||
///
|
||||
/// Parsing is deliberately total: an unknown `type` never throws, it becomes a
|
||||
/// [PmUnknown] so the renderer can fall back gracefully (schemaVersion drift).
|
||||
sealed class PmNode {
|
||||
const PmNode();
|
||||
|
||||
/// Block/inline children. Empty for leaf nodes.
|
||||
List<PmNode> get children => const [];
|
||||
|
||||
factory PmNode.fromJson(Map<String, dynamic> json) {
|
||||
final type = json['type'];
|
||||
final attrs = _attrs(json['attrs']);
|
||||
switch (type) {
|
||||
case 'paragraph':
|
||||
return PmParagraph(
|
||||
align: _parseTextAlign(attrs['textAlign']),
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
case 'heading':
|
||||
return PmHeading(
|
||||
level: _parseInt(attrs['level'], fallback: 1, min: 1, max: 6),
|
||||
align: _parseTextAlign(attrs['textAlign']),
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
case 'text':
|
||||
return PmText(
|
||||
text: json['text'] is String ? json['text'] as String : '',
|
||||
marks: _parseMarks(json['marks']),
|
||||
);
|
||||
case 'bulletList':
|
||||
return PmBulletList(children: _parseChildren(json['content']));
|
||||
case 'orderedList':
|
||||
return PmOrderedList(
|
||||
start: _parseInt(attrs['start'], fallback: 1, min: 1),
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
case 'listItem':
|
||||
return PmListItem(children: _parseChildren(json['content']));
|
||||
case 'blockquote':
|
||||
return PmBlockquote(children: _parseChildren(json['content']));
|
||||
case 'codeBlock':
|
||||
return PmCodeBlock(
|
||||
language: attrs['language'] is String
|
||||
? attrs['language'] as String
|
||||
: null,
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
case 'horizontalRule':
|
||||
return const PmHorizontalRule();
|
||||
case 'hardBreak':
|
||||
return const PmHardBreak();
|
||||
case 'image':
|
||||
return PmImage.fromAttrs(attrs);
|
||||
case 'callout':
|
||||
return PmCallout(
|
||||
variant: PmCalloutVariant.parse(attrs['variant']),
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
case 'table':
|
||||
return PmTable(children: _parseChildren(json['content']));
|
||||
case 'tableRow':
|
||||
return PmTableRow(children: _parseChildren(json['content']));
|
||||
case 'tableHeader':
|
||||
case 'tableCell':
|
||||
return PmTableCell(
|
||||
header: type == 'tableHeader',
|
||||
colspan: _parseInt(attrs['colspan'], fallback: 1, min: 1),
|
||||
rowspan: _parseInt(attrs['rowspan'], fallback: 1, min: 1),
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
default:
|
||||
return PmUnknown(
|
||||
rawType: type is String ? type : 'unknown',
|
||||
children: _parseChildren(json['content']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static List<PmNode> _parseChildren(dynamic content) {
|
||||
if (content is! List) return const [];
|
||||
return content
|
||||
.whereType<Map<dynamic, dynamic>>()
|
||||
.map((e) => PmNode.fromJson(e.cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static List<PmMark> _parseMarks(dynamic marks) {
|
||||
if (marks is! List) return const [];
|
||||
return marks
|
||||
.whereType<Map<dynamic, dynamic>>()
|
||||
.map((m) {
|
||||
final map = m.cast<String, dynamic>();
|
||||
final type = map['type'];
|
||||
return PmMark(
|
||||
type: type is String ? type : '',
|
||||
attrs: _attrs(map['attrs']),
|
||||
);
|
||||
})
|
||||
.where((m) => m.type.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _attrs(dynamic attrs) =>
|
||||
attrs is Map ? attrs.cast<String, dynamic>() : const {};
|
||||
|
||||
static TextAlign? _parseTextAlign(dynamic value) {
|
||||
switch (value) {
|
||||
case 'left':
|
||||
return TextAlign.left;
|
||||
case 'right':
|
||||
return TextAlign.right;
|
||||
case 'center':
|
||||
return TextAlign.center;
|
||||
case 'justify':
|
||||
return TextAlign.justify;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static int _parseInt(
|
||||
dynamic value, {
|
||||
required int fallback,
|
||||
int? min,
|
||||
int? max,
|
||||
}) {
|
||||
var result = fallback;
|
||||
if (value is num) result = value.toInt();
|
||||
if (value is String) result = int.tryParse(value) ?? fallback;
|
||||
if (min != null && result < min) result = min;
|
||||
if (max != null && result > max) result = max;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// A formatting mark on a [PmText] run (bold, link, highlight, …). Unknown mark
|
||||
/// types are kept verbatim and simply ignored by the renderer.
|
||||
class PmMark {
|
||||
final String type;
|
||||
final Map<String, dynamic> attrs;
|
||||
|
||||
const PmMark({required this.type, this.attrs = const {}});
|
||||
}
|
||||
|
||||
class PmParagraph extends PmNode {
|
||||
final TextAlign? align;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmParagraph({this.align, this.children = const []});
|
||||
}
|
||||
|
||||
class PmHeading extends PmNode {
|
||||
final int level;
|
||||
final TextAlign? align;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmHeading({required this.level, this.align, this.children = const []});
|
||||
}
|
||||
|
||||
class PmText extends PmNode {
|
||||
final String text;
|
||||
final List<PmMark> marks;
|
||||
|
||||
const PmText({required this.text, this.marks = const []});
|
||||
}
|
||||
|
||||
class PmBulletList extends PmNode {
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmBulletList({this.children = const []});
|
||||
}
|
||||
|
||||
class PmOrderedList extends PmNode {
|
||||
final int start;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmOrderedList({this.start = 1, this.children = const []});
|
||||
}
|
||||
|
||||
class PmListItem extends PmNode {
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmListItem({this.children = const []});
|
||||
}
|
||||
|
||||
class PmBlockquote extends PmNode {
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmBlockquote({this.children = const []});
|
||||
}
|
||||
|
||||
class PmCodeBlock extends PmNode {
|
||||
final String? language;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmCodeBlock({this.language, this.children = const []});
|
||||
}
|
||||
|
||||
class PmHorizontalRule extends PmNode {
|
||||
const PmHorizontalRule();
|
||||
}
|
||||
|
||||
class PmHardBreak extends PmNode {
|
||||
const PmHardBreak();
|
||||
}
|
||||
|
||||
class PmImage extends PmNode {
|
||||
final String src;
|
||||
final String? alt;
|
||||
final String? title;
|
||||
final String? align;
|
||||
final String? width;
|
||||
final String? href;
|
||||
|
||||
/// Base64 `data:image/` payloads are decoded exactly once at parse time and
|
||||
/// memoised here — never re-decoded in `build()`. `null` means either a
|
||||
/// network source or a decode failure (renderer shows a broken-image icon).
|
||||
final Uint8List? bytes;
|
||||
|
||||
const PmImage({
|
||||
required this.src,
|
||||
this.alt,
|
||||
this.title,
|
||||
this.align,
|
||||
this.width,
|
||||
this.href,
|
||||
this.bytes,
|
||||
});
|
||||
|
||||
factory PmImage.fromAttrs(Map<String, dynamic> attrs) {
|
||||
final src = attrs['src'] is String ? attrs['src'] as String : '';
|
||||
return PmImage(
|
||||
src: src,
|
||||
alt: attrs['alt'] is String ? attrs['alt'] as String : null,
|
||||
title: attrs['title'] is String ? attrs['title'] as String : null,
|
||||
align: attrs['align'] is String ? attrs['align'] as String : null,
|
||||
width: attrs['width'] is String ? attrs['width'] as String : null,
|
||||
href: attrs['href'] is String ? attrs['href'] as String : null,
|
||||
bytes: _decodeDataImage(src),
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List? _decodeDataImage(String src) {
|
||||
if (!src.startsWith('data:image/')) return null;
|
||||
final comma = src.indexOf(',');
|
||||
if (comma < 0) return null;
|
||||
if (!src.substring(0, comma).contains(';base64')) return null;
|
||||
try {
|
||||
return base64Decode(src.substring(comma + 1));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PmCalloutVariant {
|
||||
info,
|
||||
tip,
|
||||
success,
|
||||
warning,
|
||||
important;
|
||||
|
||||
static PmCalloutVariant parse(dynamic value) {
|
||||
for (final variant in PmCalloutVariant.values) {
|
||||
if (variant.name == value) return variant;
|
||||
}
|
||||
return PmCalloutVariant.info;
|
||||
}
|
||||
}
|
||||
|
||||
class PmCallout extends PmNode {
|
||||
final PmCalloutVariant variant;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmCallout({required this.variant, this.children = const []});
|
||||
}
|
||||
|
||||
class PmTable extends PmNode {
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmTable({this.children = const []});
|
||||
}
|
||||
|
||||
class PmTableRow extends PmNode {
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmTableRow({this.children = const []});
|
||||
}
|
||||
|
||||
class PmTableCell extends PmNode {
|
||||
final bool header;
|
||||
final int colspan;
|
||||
final int rowspan;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmTableCell({
|
||||
this.header = false,
|
||||
this.colspan = 1,
|
||||
this.rowspan = 1,
|
||||
this.children = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class PmUnknown extends PmNode {
|
||||
final String rawType;
|
||||
@override
|
||||
final List<PmNode> children;
|
||||
|
||||
const PmUnknown({required this.rawType, this.children = const []});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Carries render-time collaborators (the link-tap hook) down the node tree so
|
||||
/// individual node widgets stay pure `(node) => Widget` builders.
|
||||
class PmRenderScope extends InheritedWidget {
|
||||
/// Invoked when a link mark or an image `href` is tapped. The navigation /
|
||||
/// URL policy is injected from outside — the renderer only exposes the hook.
|
||||
final void Function(String href)? onLinkTap;
|
||||
|
||||
const PmRenderScope({
|
||||
required this.onLinkTap,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static PmRenderScope? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<PmRenderScope>();
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PmRenderScope oldWidget) =>
|
||||
oldWidget.onLinkTap != onLinkTap;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'pm_node.dart';
|
||||
import 'pm_render_scope.dart';
|
||||
|
||||
/// Renders a run of inline nodes ([PmText] / [PmHardBreak]) as a single
|
||||
/// `Text.rich`, composing marks into `TextStyle`s.
|
||||
///
|
||||
/// Link marks need a [TapGestureRecognizer], which must be disposed to avoid
|
||||
/// leaks. The recognizers are therefore built in [didChangeDependencies] /
|
||||
/// [didUpdateWidget] and released in [dispose] — never allocated inside
|
||||
/// [build].
|
||||
class PmRichText extends StatefulWidget {
|
||||
final List<PmNode> inlines;
|
||||
final TextStyle baseStyle;
|
||||
final TextAlign textAlign;
|
||||
|
||||
const PmRichText({
|
||||
required this.inlines,
|
||||
required this.baseStyle,
|
||||
this.textAlign = TextAlign.start,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PmRichText> createState() => _PmRichTextState();
|
||||
}
|
||||
|
||||
class _PmRichTextState extends State<PmRichText> {
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
InlineSpan _span = const TextSpan();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_rebuild();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PmRichText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.inlines != widget.inlines ||
|
||||
oldWidget.baseStyle != widget.baseStyle ||
|
||||
oldWidget.textAlign != widget.textAlign) {
|
||||
_rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
void _rebuild() {
|
||||
_disposeRecognizers();
|
||||
final theme = Theme.of(context);
|
||||
final onLinkTap = PmRenderScope.maybeOf(context)?.onLinkTap;
|
||||
final spans = <InlineSpan>[];
|
||||
for (final node in widget.inlines) {
|
||||
if (node is PmHardBreak) {
|
||||
spans.add(const TextSpan(text: '\n'));
|
||||
} else if (node is PmText) {
|
||||
spans.add(_textSpan(node, theme, onLinkTap));
|
||||
}
|
||||
}
|
||||
_span = TextSpan(style: widget.baseStyle, children: spans);
|
||||
}
|
||||
|
||||
TextSpan _textSpan(
|
||||
PmText node,
|
||||
ThemeData theme,
|
||||
void Function(String href)? onLinkTap,
|
||||
) {
|
||||
String? href;
|
||||
final style = _styleForMarks(
|
||||
node.marks,
|
||||
widget.baseStyle,
|
||||
theme,
|
||||
onLink: (value) => href = value,
|
||||
);
|
||||
TapGestureRecognizer? recognizer;
|
||||
if (href != null && onLinkTap != null) {
|
||||
final target = href!;
|
||||
recognizer = TapGestureRecognizer()..onTap = () => onLinkTap(target);
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
return TextSpan(text: node.text, style: style, recognizer: recognizer);
|
||||
}
|
||||
|
||||
TextStyle _styleForMarks(
|
||||
List<PmMark> marks,
|
||||
TextStyle base,
|
||||
ThemeData theme, {
|
||||
required void Function(String href) onLink,
|
||||
}) {
|
||||
var style = base;
|
||||
final decorations = <TextDecoration>[];
|
||||
for (final mark in marks) {
|
||||
switch (mark.type) {
|
||||
case 'bold':
|
||||
style = style.copyWith(fontWeight: FontWeight.bold);
|
||||
case 'italic':
|
||||
style = style.copyWith(fontStyle: FontStyle.italic);
|
||||
case 'strike':
|
||||
decorations.add(TextDecoration.lineThrough);
|
||||
case 'underline':
|
||||
decorations.add(TextDecoration.underline);
|
||||
case 'code':
|
||||
style = style.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
case 'highlight':
|
||||
style = style.copyWith(
|
||||
backgroundColor: _highlightColor(mark.attrs['color'], theme),
|
||||
);
|
||||
case 'textStyle':
|
||||
final size = _fontSize(mark.attrs['fontSize'], base.fontSize ?? 14);
|
||||
if (size != null) style = style.copyWith(fontSize: size);
|
||||
case 'link':
|
||||
final rawHref = mark.attrs['href'];
|
||||
if (rawHref is String && rawHref.isNotEmpty) {
|
||||
onLink(rawHref);
|
||||
style = style.copyWith(color: theme.colorScheme.primary);
|
||||
decorations.add(TextDecoration.underline);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (decorations.isNotEmpty) {
|
||||
style = style.copyWith(decoration: TextDecoration.combine(decorations));
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
Color _highlightColor(dynamic color, ThemeData theme) {
|
||||
final parsed = color is String ? _parseCssColor(color) : null;
|
||||
final base = parsed ?? theme.colorScheme.tertiaryContainer;
|
||||
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
|
||||
// down further there while keeping the accent readable in light mode.
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
return base.withValues(alpha: dark ? 0.30 : 0.45);
|
||||
}
|
||||
|
||||
double? _fontSize(dynamic raw, double base) {
|
||||
if (raw is! String) return null;
|
||||
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw);
|
||||
if (match == null) return null;
|
||||
final value = double.parse(match.group(1)!);
|
||||
final double size;
|
||||
switch (match.group(2)) {
|
||||
case 'px':
|
||||
size = value;
|
||||
case 'rem':
|
||||
size = value * 16;
|
||||
case 'em':
|
||||
size = base * value;
|
||||
case '%':
|
||||
size = base * value / 100;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return size.clamp(8, 72);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text.rich(_span, textAlign: widget.textAlign);
|
||||
}
|
||||
|
||||
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
|
||||
/// Returns `null` for anything unrecognised so the caller can fall back.
|
||||
Color? _parseCssColor(String raw) {
|
||||
final value = raw.trim().toLowerCase();
|
||||
if (value.startsWith('#')) {
|
||||
var hex = value.substring(1);
|
||||
if (hex.length == 3) {
|
||||
hex = hex.split('').map((c) => '$c$c').join();
|
||||
}
|
||||
if (hex.length == 6) hex = 'ff$hex';
|
||||
if (hex.length == 8) {
|
||||
final rgba = int.tryParse(hex, radix: 16);
|
||||
if (rgba != null) return Color(rgba);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return _namedColors[value];
|
||||
}
|
||||
|
||||
const _namedColors = <String, Color>{
|
||||
'red': Color(0xFFEF4444),
|
||||
'orange': Color(0xFFF97316),
|
||||
'yellow': Color(0xFFFACC15),
|
||||
'green': Color(0xFF22C55E),
|
||||
'blue': Color(0xFF3B82F6),
|
||||
'purple': Color(0xFFA855F7),
|
||||
'pink': Color(0xFFEC4899),
|
||||
'gray': Color(0xFF9CA3AF),
|
||||
'grey': Color(0xFF9CA3AF),
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_layout_grid/flutter_layout_grid.dart';
|
||||
|
||||
import '../../theming/app_theme.dart';
|
||||
import 'pm_document_view.dart';
|
||||
import 'pm_node.dart';
|
||||
|
||||
/// Renders a ProseMirror table. Flutter's built-in `Table` cannot span cells,
|
||||
/// so `flutter_layout_grid` places each cell explicitly, honouring
|
||||
/// colspan/rowspan via a simple HTML-style occupancy scan.
|
||||
class PmTableView extends StatelessWidget {
|
||||
final PmTable node;
|
||||
|
||||
static const double _minColumnWidth = 140;
|
||||
|
||||
const PmTableView({required this.node, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = node.children.whereType<PmTableRow>().toList();
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final occupied = <int, Set<int>>{};
|
||||
final placements = <Widget>[];
|
||||
var columnCount = 0;
|
||||
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
var col = 0;
|
||||
for (final cell in rows[r].children.whereType<PmTableCell>()) {
|
||||
while (occupied[r]?.contains(col) ?? false) {
|
||||
col++;
|
||||
}
|
||||
placements.add(
|
||||
GridPlacement(
|
||||
columnStart: col,
|
||||
columnSpan: cell.colspan,
|
||||
rowStart: r,
|
||||
rowSpan: cell.rowspan,
|
||||
child: _cell(context, cell),
|
||||
),
|
||||
);
|
||||
for (var dr = 0; dr < cell.rowspan; dr++) {
|
||||
final set = occupied[r + dr] ??= <int>{};
|
||||
for (var dc = 0; dc < cell.colspan; dc++) {
|
||||
set.add(col + dc);
|
||||
}
|
||||
}
|
||||
col += cell.colspan;
|
||||
columnCount = max(columnCount, col);
|
||||
}
|
||||
}
|
||||
if (columnCount == 0) return const SizedBox.shrink();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: _minColumnWidth * columnCount;
|
||||
final columnWidth = max(_minColumnWidth, available / columnCount);
|
||||
final totalWidth = columnWidth * columnCount;
|
||||
|
||||
final grid = SizedBox(
|
||||
width: totalWidth,
|
||||
child: LayoutGrid(
|
||||
columnSizes: List.filled(columnCount, fixed(columnWidth)),
|
||||
rowSizes: List.filled(rows.length, auto),
|
||||
children: placements,
|
||||
),
|
||||
);
|
||||
|
||||
if (totalWidth <= available) return grid;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(BuildContext context, PmTableCell cell) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cell.header ? theme.colorScheme.surfaceContainerHighest : null,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: pmBlocks(cell.children, gap: AppSpacing.xs),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user