252 lines
7.1 KiB
Dart
252 lines
7.1 KiB
Dart
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();
|
|
}
|