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,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();
|
||||
}
|
||||
Reference in New Issue
Block a user