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

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 []});
}