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

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();
}