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:
2026-07-09 00:51:08 +02:00
parent 1114291313
commit cedeb06569
57 changed files with 4758 additions and 43 deletions
@@ -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();
}
+135
View File
@@ -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);
}
}
+124
View File
@@ -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);
}
}
+327
View File
@@ -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;
}
+208
View File
@@ -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),
};
+95
View File
@@ -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),
),
);
}
}