328 lines
8.6 KiB
Dart
328 lines
8.6 KiB
Dart
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 []});
|
|
}
|