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 get children => const []; factory PmNode.fromJson(Map 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 _parseChildren(dynamic content) { if (content is! List) return const []; return content .whereType>() .map((e) => PmNode.fromJson(e.cast())) .toList(growable: false); } static List _parseMarks(dynamic marks) { if (marks is! List) return const []; return marks .whereType>() .map((m) { final map = m.cast(); 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 _attrs(dynamic attrs) => attrs is Map ? attrs.cast() : 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 attrs; const PmMark({required this.type, this.attrs = const {}}); } class PmParagraph extends PmNode { final TextAlign? align; @override final List children; const PmParagraph({this.align, this.children = const []}); } class PmHeading extends PmNode { final int level; final TextAlign? align; @override final List children; const PmHeading({required this.level, this.align, this.children = const []}); } class PmText extends PmNode { final String text; final List marks; const PmText({required this.text, this.marks = const []}); } class PmBulletList extends PmNode { @override final List children; const PmBulletList({this.children = const []}); } class PmOrderedList extends PmNode { final int start; @override final List children; const PmOrderedList({this.start = 1, this.children = const []}); } class PmListItem extends PmNode { @override final List children; const PmListItem({this.children = const []}); } class PmBlockquote extends PmNode { @override final List children; const PmBlockquote({this.children = const []}); } class PmCodeBlock extends PmNode { final String? language; @override final List 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 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 children; const PmCallout({required this.variant, this.children = const []}); } class PmTable extends PmNode { @override final List children; const PmTable({this.children = const []}); } class PmTableRow extends PmNode { @override final List children; const PmTableRow({this.children = const []}); } class PmTableCell extends PmNode { final bool header; final int colspan; final int rowspan; @override final List 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 children; const PmUnknown({required this.rawType, this.children = const []}); }