diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f4707..f12566b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [0.2.1] - 2026-03-25 — Viewer completeness: embed, linkTool, attaches, raw + +### New block types (viewer only) +* `embed` — tappable card showing service name, source URL, and optional caption; opens URL via `url_launcher`. +* `linkTool` — link preview card with thumbnail, title, description, and URL; opens link via `url_launcher`. +* `attaches` — file download card with type-specific icon, file size, and `url_launcher` download action. +* `raw` — raw HTML content rendered through `flutter_html` with full `HtmlSanitizer` protection. + +### Cross-cutting renderer improvements +* Extracted `HtmlStyleBuilder` shared utility — applies `defaultFont` to `flutter_html` `body` style and all CSS tag overrides, eliminating duplicate helpers across renderers. +* Applied `HtmlSanitizer.sanitize()` to `QuoteRenderer` and `ListRenderer` (HTML content fields). +* `ImageRenderer` caption now respects `styleConfig.defaultFont`. + +### Exports +* All four new block entity classes (`EmbedBlock`, `LinkToolBlock`, `AttachesBlock`, `RawBlock`) exported from the public barrel file. + +--- + ## [0.2.0] - 2026-03-25 — Phase 2: Viewer & editor completeness ### New block types (viewer + editor) diff --git a/lib/editorjs_flutter.dart b/lib/editorjs_flutter.dart index c9ce0fd..9dc6ce4 100644 --- a/lib/editorjs_flutter.dart +++ b/lib/editorjs_flutter.dart @@ -33,3 +33,7 @@ export 'src/domain/entities/blocks/code_block.dart'; export 'src/domain/entities/blocks/checklist_block.dart'; export 'src/domain/entities/blocks/table_block.dart'; export 'src/domain/entities/blocks/warning_block.dart'; +export 'src/domain/entities/blocks/embed_block.dart'; +export 'src/domain/entities/blocks/link_tool_block.dart'; +export 'src/domain/entities/blocks/attaches_block.dart'; +export 'src/domain/entities/blocks/raw_block.dart'; diff --git a/lib/src/data/mappers/attaches_mapper.dart b/lib/src/data/mappers/attaches_mapper.dart new file mode 100644 index 0000000..4501996 --- /dev/null +++ b/lib/src/data/mappers/attaches_mapper.dart @@ -0,0 +1,21 @@ +import '../../domain/entities/blocks/attaches_block.dart'; +import 'block_mapper.dart'; + +class AttachesMapper implements BlockMapper { + const AttachesMapper(); + + @override + String get supportedType => 'attaches'; + + @override + AttachesBlock fromJson(Map data) { + final file = data['file'] as Map?; + return AttachesBlock( + url: (file?['url'] as String?) ?? '', + name: file?['name'] as String?, + extension: file?['extension'] as String?, + size: file?['size'] as int?, + title: data['title'] as String?, + ); + } +} diff --git a/lib/src/data/mappers/embed_mapper.dart b/lib/src/data/mappers/embed_mapper.dart new file mode 100644 index 0000000..ac2e4e7 --- /dev/null +++ b/lib/src/data/mappers/embed_mapper.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/blocks/embed_block.dart'; +import 'block_mapper.dart'; + +class EmbedMapper implements BlockMapper { + const EmbedMapper(); + + @override + String get supportedType => 'embed'; + + @override + EmbedBlock fromJson(Map data) => EmbedBlock( + service: (data['service'] as String?) ?? '', + source: (data['source'] as String?) ?? '', + embed: (data['embed'] as String?) ?? '', + width: data['width'] as int?, + height: data['height'] as int?, + caption: data['caption'] as String?, + ); +} diff --git a/lib/src/data/mappers/link_tool_mapper.dart b/lib/src/data/mappers/link_tool_mapper.dart new file mode 100644 index 0000000..ab12552 --- /dev/null +++ b/lib/src/data/mappers/link_tool_mapper.dart @@ -0,0 +1,28 @@ +import '../../domain/entities/blocks/link_tool_block.dart'; +import 'block_mapper.dart'; + +class LinkToolMapper implements BlockMapper { + const LinkToolMapper(); + + @override + String get supportedType => 'linkTool'; + + @override + LinkToolBlock fromJson(Map data) { + final rawMeta = data['meta'] as Map?; + final rawImage = rawMeta?['image'] as Map?; + + final meta = rawMeta == null + ? null + : LinkToolMeta( + title: rawMeta['title'] as String?, + description: rawMeta['description'] as String?, + imageUrl: rawImage?['url'] as String?, + ); + + return LinkToolBlock( + link: (data['link'] as String?) ?? '', + meta: meta, + ); + } +} diff --git a/lib/src/data/mappers/raw_mapper.dart b/lib/src/data/mappers/raw_mapper.dart new file mode 100644 index 0000000..668529c --- /dev/null +++ b/lib/src/data/mappers/raw_mapper.dart @@ -0,0 +1,13 @@ +import '../../domain/entities/blocks/raw_block.dart'; +import 'block_mapper.dart'; + +class RawMapper implements BlockMapper { + const RawMapper(); + + @override + String get supportedType => 'raw'; + + @override + RawBlock fromJson(Map data) => + RawBlock(html: (data['html'] as String?) ?? ''); +} diff --git a/lib/src/data/registry/block_type_registry.dart b/lib/src/data/registry/block_type_registry.dart index 65cf181..6811b37 100644 --- a/lib/src/data/registry/block_type_registry.dart +++ b/lib/src/data/registry/block_type_registry.dart @@ -1,13 +1,17 @@ import '../../domain/entities/block_entity.dart'; +import '../mappers/attaches_mapper.dart'; import '../mappers/block_mapper.dart'; import '../mappers/checklist_mapper.dart'; import '../mappers/code_mapper.dart'; import '../mappers/delimiter_mapper.dart'; +import '../mappers/embed_mapper.dart'; import '../mappers/header_mapper.dart'; import '../mappers/image_mapper.dart'; +import '../mappers/link_tool_mapper.dart'; import '../mappers/list_mapper.dart'; import '../mappers/paragraph_mapper.dart'; import '../mappers/quote_mapper.dart'; +import '../mappers/raw_mapper.dart'; import '../mappers/table_mapper.dart'; import '../mappers/warning_mapper.dart'; @@ -29,6 +33,10 @@ class BlockTypeRegistry { register(const ChecklistMapper()); register(const TableMapper()); register(const WarningMapper()); + register(const EmbedMapper()); + register(const LinkToolMapper()); + register(const AttachesMapper()); + register(const RawMapper()); } /// Registers a [BlockMapper]. Overwrites any existing mapper for the same type. diff --git a/lib/src/domain/entities/blocks/attaches_block.dart b/lib/src/domain/entities/blocks/attaches_block.dart new file mode 100644 index 0000000..c58ad05 --- /dev/null +++ b/lib/src/domain/entities/blocks/attaches_block.dart @@ -0,0 +1,34 @@ +import '../block_entity.dart'; + +class AttachesBlock extends BlockEntity { + final String url; + final String? name; + final String? extension; + + /// File size in bytes. Null if not provided. + final int? size; + + final String? title; + + const AttachesBlock({ + required this.url, + this.name, + this.extension, + this.size, + this.title, + }); + + @override + String get type => 'attaches'; + + @override + Map toJson() => { + 'file': { + 'url': url, + if (name != null) 'name': name, + if (extension != null) 'extension': extension, + if (size != null) 'size': size, + }, + 'title': title ?? '', + }; +} diff --git a/lib/src/domain/entities/blocks/embed_block.dart b/lib/src/domain/entities/blocks/embed_block.dart new file mode 100644 index 0000000..93b1e86 --- /dev/null +++ b/lib/src/domain/entities/blocks/embed_block.dart @@ -0,0 +1,39 @@ +import '../block_entity.dart'; + +class EmbedBlock extends BlockEntity { + /// The service name (e.g. 'youtube', 'vimeo', 'codepen'). + final String service; + + /// Original source URL as entered by the user. + final String source; + + /// Embed URL (typically an iframe src). Not used for rendering in Flutter, + /// but preserved for round-trip JSON serialization. + final String embed; + + final int? width; + final int? height; + final String? caption; + + const EmbedBlock({ + required this.service, + required this.source, + required this.embed, + this.width, + this.height, + this.caption, + }); + + @override + String get type => 'embed'; + + @override + Map toJson() => { + 'service': service, + 'source': source, + 'embed': embed, + if (width != null) 'width': width, + if (height != null) 'height': height, + 'caption': caption ?? '', + }; +} diff --git a/lib/src/domain/entities/blocks/link_tool_block.dart b/lib/src/domain/entities/blocks/link_tool_block.dart new file mode 100644 index 0000000..c4a3464 --- /dev/null +++ b/lib/src/domain/entities/blocks/link_tool_block.dart @@ -0,0 +1,31 @@ +import '../block_entity.dart'; + +class LinkToolMeta { + final String? title; + final String? description; + final String? imageUrl; + + const LinkToolMeta({this.title, this.description, this.imageUrl}); + + Map toJson() => { + if (title != null) 'title': title, + if (description != null) 'description': description, + if (imageUrl != null) 'image': {'url': imageUrl}, + }; +} + +class LinkToolBlock extends BlockEntity { + final String link; + final LinkToolMeta? meta; + + const LinkToolBlock({required this.link, this.meta}); + + @override + String get type => 'linkTool'; + + @override + Map toJson() => { + 'link': link, + if (meta != null) 'meta': meta!.toJson(), + }; +} diff --git a/lib/src/domain/entities/blocks/raw_block.dart b/lib/src/domain/entities/blocks/raw_block.dart new file mode 100644 index 0000000..0edc79c --- /dev/null +++ b/lib/src/domain/entities/blocks/raw_block.dart @@ -0,0 +1,14 @@ +import '../block_entity.dart'; + +/// Raw HTML block. Content is sanitized before rendering. +class RawBlock extends BlockEntity { + final String html; + + const RawBlock({required this.html}); + + @override + String get type => 'raw'; + + @override + Map toJson() => {'html': html}; +} diff --git a/lib/src/presentation/blocks/attaches/attaches_renderer.dart b/lib/src/presentation/blocks/attaches/attaches_renderer.dart new file mode 100644 index 0000000..29467aa --- /dev/null +++ b/lib/src/presentation/blocks/attaches/attaches_renderer.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../domain/entities/blocks/attaches_block.dart'; +import '../base_block_renderer.dart'; + +class AttachesRenderer extends BlockRenderer { + const AttachesRenderer( + {super.key, required super.block, super.styleConfig}); + + @override + Widget build(BuildContext context) { + final displayName = + block.title ?? block.name ?? 'Download file'; + final ext = block.extension?.toUpperCase(); + + return InkWell( + onTap: block.url.isNotEmpty + ? () async { + final uri = Uri.tryParse(block.url); + if (uri == null) return; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') return; + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + : null, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + _iconForExtension(block.extension), + color: Colors.blue.shade700, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + fontFamily: styleConfig?.defaultFont, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (ext != null || block.size != null) + Text( + [ + if (ext != null) ext, + if (block.size != null) _formatSize(block.size!), + ].join(' · '), + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontFamily: styleConfig?.defaultFont, + ), + ), + ], + ), + ), + Icon(Icons.download_outlined, + color: Colors.blue.shade700, size: 20), + ], + ), + ), + ); + } + + IconData _iconForExtension(String? ext) => switch (ext?.toLowerCase()) { + 'pdf' => Icons.picture_as_pdf_outlined, + 'doc' || 'docx' => Icons.description_outlined, + 'xls' || 'xlsx' => Icons.table_chart_outlined, + 'zip' || 'rar' || '7z' => Icons.folder_zip_outlined, + 'mp3' || 'wav' || 'ogg' => Icons.audio_file_outlined, + 'mp4' || 'mov' || 'avi' => Icons.video_file_outlined, + _ => Icons.insert_drive_file_outlined, + }; + + String _formatSize(int bytes) { + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } +} diff --git a/lib/src/presentation/blocks/embed/embed_renderer.dart b/lib/src/presentation/blocks/embed/embed_renderer.dart new file mode 100644 index 0000000..0c14d99 --- /dev/null +++ b/lib/src/presentation/blocks/embed/embed_renderer.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../domain/entities/blocks/embed_block.dart'; +import '../base_block_renderer.dart'; + +class EmbedRenderer extends BlockRenderer { + const EmbedRenderer({super.key, required super.block, super.styleConfig}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: block.source.isNotEmpty + ? () async { + final uri = Uri.tryParse(block.source); + if (uri == null) return; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') return; + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } + } + : null, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Service banner + Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + child: Row( + children: [ + const Icon(Icons.play_circle_outline, + size: 18, color: Colors.black54), + const SizedBox(width: 8), + Text( + block.service.isNotEmpty + ? _capitalise(block.service) + : 'Embedded content', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Colors.black87, + fontFamily: styleConfig?.defaultFont, + ), + ), + const Spacer(), + const Icon(Icons.open_in_new, + size: 14, color: Colors.black45), + ], + ), + ), + // Source URL + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + block.source, + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade700, + decoration: TextDecoration.underline, + fontFamily: styleConfig?.defaultFont, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Caption + if (block.caption != null && block.caption!.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: Text( + block.caption!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + fontFamily: styleConfig?.defaultFont, + ), + ), + ), + ], + ), + ), + ); + } + + String _capitalise(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; +} diff --git a/lib/src/presentation/blocks/image/image_renderer.dart b/lib/src/presentation/blocks/image/image_renderer.dart index 21ec8ff..2b79734 100644 --- a/lib/src/presentation/blocks/image/image_renderer.dart +++ b/lib/src/presentation/blocks/image/image_renderer.dart @@ -53,6 +53,7 @@ class ImageRenderer extends BlockRenderer { fontSize: 12, color: Colors.grey.shade600, fontStyle: FontStyle.italic, + fontFamily: styleConfig?.defaultFont, ), ), ), diff --git a/lib/src/presentation/blocks/link_tool/link_tool_renderer.dart b/lib/src/presentation/blocks/link_tool/link_tool_renderer.dart new file mode 100644 index 0000000..7cb5ef9 --- /dev/null +++ b/lib/src/presentation/blocks/link_tool/link_tool_renderer.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../domain/entities/blocks/link_tool_block.dart'; +import '../base_block_renderer.dart'; + +class LinkToolRenderer extends BlockRenderer { + const LinkToolRenderer( + {super.key, required super.block, super.styleConfig}); + + @override + Widget build(BuildContext context) { + final meta = block.meta; + + return InkWell( + onTap: block.link.isNotEmpty + ? () async { + final uri = Uri.tryParse(block.link); + if (uri == null) { + return; + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return; + } + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } + } + : null, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Thumbnail + if (meta?.imageUrl != null && meta!.imageUrl!.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(8)), + child: Image.network( + meta.imageUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const _LinkPlaceholderIcon(), + ), + ) + else + const _LinkPlaceholderIcon(), + // Text content + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (meta?.title != null && meta!.title!.isNotEmpty) + Text( + meta.title!, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + fontFamily: styleConfig?.defaultFont, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (meta?.description != null && + meta!.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + meta.description!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontFamily: styleConfig?.defaultFont, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + block.link, + style: TextStyle( + fontSize: 11, + color: Colors.blue.shade700, + fontFamily: styleConfig?.defaultFont, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _LinkPlaceholderIcon extends StatelessWidget { + const _LinkPlaceholderIcon(); + + @override + Widget build(BuildContext context) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: + const BorderRadius.horizontal(left: Radius.circular(8)), + ), + child: const Icon(Icons.link, color: Colors.grey), + ); + } +} diff --git a/lib/src/presentation/blocks/list/list_renderer.dart b/lib/src/presentation/blocks/list/list_renderer.dart index b02cdf8..6cc74b1 100644 --- a/lib/src/presentation/blocks/list/list_renderer.dart +++ b/lib/src/presentation/blocks/list/list_renderer.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import '../../../data/utils/html_sanitizer.dart'; import '../../../domain/entities/blocks/list_block.dart'; import '../../../domain/entities/style_config.dart'; +import '../../utils/html_style_builder.dart'; import '../base_block_renderer.dart'; class ListRenderer extends BlockRenderer { @@ -43,8 +45,8 @@ class ListRenderer extends BlockRenderer { ), Expanded( child: Html( - data: item.content, - style: _buildStyleMap(styleConfig), + data: HtmlSanitizer.sanitize(item.content), + style: HtmlStyleBuilder.build(styleConfig), ), ), ], @@ -54,22 +56,4 @@ class ListRenderer extends BlockRenderer { ], ]; } - - static Map _buildStyleMap(StyleConfig? config) { - if (config == null || config.cssTags.isEmpty) return {}; - return { - for (final tag in config.cssTags) - tag.tag: Style( - backgroundColor: - tag.backgroundColor != null ? _parseColor(tag.backgroundColor!) : null, - color: tag.color != null ? _parseColor(tag.color!) : null, - padding: tag.padding != null ? HtmlPaddings.all(tag.padding!) : null, - ), - }; - } - - static Color _parseColor(String hex) { - final code = hex.replaceAll('#', ''); - return Color(int.parse(code, radix: 16)); - } } diff --git a/lib/src/presentation/blocks/paragraph/paragraph_renderer.dart b/lib/src/presentation/blocks/paragraph/paragraph_renderer.dart index 848b6a8..bbfdd44 100644 --- a/lib/src/presentation/blocks/paragraph/paragraph_renderer.dart +++ b/lib/src/presentation/blocks/paragraph/paragraph_renderer.dart @@ -3,8 +3,8 @@ import 'package:flutter_html/flutter_html.dart'; import '../../../data/utils/html_sanitizer.dart'; import '../../../domain/entities/blocks/paragraph_block.dart'; -import '../../../domain/entities/style_config.dart'; import '../base_block_renderer.dart'; +import '../../utils/html_style_builder.dart'; class ParagraphRenderer extends BlockRenderer { const ParagraphRenderer({super.key, required super.block, super.styleConfig}); @@ -13,28 +13,7 @@ class ParagraphRenderer extends BlockRenderer { Widget build(BuildContext context) { return Html( data: HtmlSanitizer.sanitize(block.html), - style: _buildStyleMap(styleConfig), + style: HtmlStyleBuilder.build(styleConfig), ); } - - Map _buildStyleMap(StyleConfig? config) { - if (config == null || config.cssTags.isEmpty) return {}; - return { - for (final tag in config.cssTags) - tag.tag: Style( - backgroundColor: tag.backgroundColor != null - ? _parseColor(tag.backgroundColor!) - : null, - color: tag.color != null ? _parseColor(tag.color!) : null, - padding: tag.padding != null - ? HtmlPaddings.all(tag.padding!) - : null, - ), - }; - } - - Color _parseColor(String hex) { - final code = hex.replaceAll('#', ''); - return Color(int.parse(code, radix: 16)); - } } diff --git a/lib/src/presentation/blocks/quote/quote_renderer.dart b/lib/src/presentation/blocks/quote/quote_renderer.dart index 4de1205..620347b 100644 --- a/lib/src/presentation/blocks/quote/quote_renderer.dart +++ b/lib/src/presentation/blocks/quote/quote_renderer.dart @@ -3,6 +3,7 @@ import 'package:flutter_html/flutter_html.dart'; import '../../../data/utils/html_sanitizer.dart'; import '../../../domain/entities/blocks/quote_block.dart'; +import '../../utils/html_style_builder.dart'; import '../base_block_renderer.dart'; class QuoteRenderer extends BlockRenderer { @@ -16,6 +17,17 @@ class QuoteRenderer extends BlockRenderer { QuoteAlignment.left => TextAlign.left, }; + final baseStyles = HtmlStyleBuilder.build(styleConfig); + final baseBodyStyle = baseStyles['body'] ?? Style(); + final mergedBodyStyle = baseBodyStyle.copyWith( + textAlign: align, + fontFamily: styleConfig?.defaultFont, + ); + final styleMap = { + ...baseStyles, + 'body': mergedBodyStyle, + }; + return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -32,9 +44,7 @@ class QuoteRenderer extends BlockRenderer { children: [ Html( data: HtmlSanitizer.sanitize(block.text), - style: { - 'body': Style(textAlign: align), - }, + style: styleMap, ), if (block.caption != null && block.caption!.isNotEmpty) Padding( diff --git a/lib/src/presentation/blocks/raw/raw_renderer.dart b/lib/src/presentation/blocks/raw/raw_renderer.dart new file mode 100644 index 0000000..58fe172 --- /dev/null +++ b/lib/src/presentation/blocks/raw/raw_renderer.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; + +import '../../../data/utils/html_sanitizer.dart'; +import '../../../domain/entities/blocks/raw_block.dart'; +import '../../utils/html_style_builder.dart'; +import '../base_block_renderer.dart'; + +/// Renders raw HTML content. Content is sanitized before display. +class RawRenderer extends BlockRenderer { + const RawRenderer({super.key, required super.block, super.styleConfig}); + + @override + Widget build(BuildContext context) { + return Html( + data: HtmlSanitizer.sanitize(block.html), + style: HtmlStyleBuilder.build(styleConfig), + ); + } +} diff --git a/lib/src/presentation/registry/block_renderer_registry.dart b/lib/src/presentation/registry/block_renderer_registry.dart index 3de4b9e..e2da99e 100644 --- a/lib/src/presentation/registry/block_renderer_registry.dart +++ b/lib/src/presentation/registry/block_renderer_registry.dart @@ -10,9 +10,17 @@ import '../../domain/entities/blocks/list_block.dart'; import '../../domain/entities/blocks/paragraph_block.dart'; import '../../domain/entities/blocks/quote_block.dart'; import '../../domain/entities/blocks/table_block.dart'; +import '../../domain/entities/blocks/attaches_block.dart'; +import '../../domain/entities/blocks/embed_block.dart'; +import '../../domain/entities/blocks/link_tool_block.dart'; +import '../../domain/entities/blocks/raw_block.dart'; import '../../domain/entities/blocks/warning_block.dart'; import '../../domain/entities/style_config.dart'; +import '../blocks/attaches/attaches_renderer.dart'; import '../blocks/checklist/checklist_editor.dart'; +import '../blocks/embed/embed_renderer.dart'; +import '../blocks/link_tool/link_tool_renderer.dart'; +import '../blocks/raw/raw_renderer.dart'; import '../blocks/checklist/checklist_renderer.dart'; import '../blocks/code/code_editor.dart'; import '../blocks/code/code_renderer.dart'; @@ -76,6 +84,14 @@ class BlockRendererRegistry { (b, s) => TableRenderer(block: b as TableBlock, styleConfig: s)); registerRenderer('warning', (b, s) => WarningRenderer(block: b as WarningBlock, styleConfig: s)); + registerRenderer('embed', + (b, s) => EmbedRenderer(block: b as EmbedBlock, styleConfig: s)); + registerRenderer('linkTool', + (b, s) => LinkToolRenderer(block: b as LinkToolBlock, styleConfig: s)); + registerRenderer('attaches', + (b, s) => AttachesRenderer(block: b as AttachesBlock, styleConfig: s)); + registerRenderer('raw', + (b, s) => RawRenderer(block: b as RawBlock, styleConfig: s)); registerEditor('header', (b, cb) => HeaderEditor(block: b as HeaderBlock, onChanged: (u) => cb(u))); diff --git a/lib/src/presentation/utils/html_style_builder.dart b/lib/src/presentation/utils/html_style_builder.dart new file mode 100644 index 0000000..84ef65b --- /dev/null +++ b/lib/src/presentation/utils/html_style_builder.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; + +import '../../domain/entities/style_config.dart'; + +/// Builds a `flutter_html` style map from a [StyleConfig]. +/// +/// Centralises font and CSS-tag styling so every HTML-rendering block +/// renderer applies them consistently without duplicating logic. +class HtmlStyleBuilder { + HtmlStyleBuilder._(); + + /// Returns a style map ready to pass to [Html.style]. + /// + /// Always includes a `body` rule that applies [StyleConfig.defaultFont] + /// when one is configured, so the font propagates to all HTML content. + static Map build(StyleConfig? config) { + final map = {}; + + if (config?.defaultFont != null) { + map['body'] = Style(fontFamily: config!.defaultFont); + } + + for (final tag in config?.cssTags ?? []) { + map[tag.tag] = Style( + backgroundColor: tag.backgroundColor != null + ? _parseColor(tag.backgroundColor!) + : null, + color: tag.color != null ? _parseColor(tag.color!) : null, + padding: + tag.padding != null ? HtmlPaddings.all(tag.padding!) : null, + fontFamily: config?.defaultFont, + ); + } + + return map; + } + + static Color _parseColor(String hex) { + // Remove leading '#' characters, if any. + final code = hex.replaceAll('#', ''); + + // Normalize to 8-digit ARGB. Accept 6-digit RGB or 8-digit ARGB; otherwise fallback. + String normalized; + if (code.length == 6) { + // Treat 6-digit RGB as fully opaque. + normalized = 'FF$code'; + } else if (code.length == 8) { + normalized = code; + } else { + // Invalid length, use a safe fallback color. + return const Color(0xFF000000); + } + + final value = int.tryParse(normalized, radix: 16); + if (value == null) { + // Invalid hex characters, use a safe fallback color. + return const Color(0xFF000000); + } + + return Color(value); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e97b1e4..4bba098 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: editorjs_flutter description: A new Flutter package project. -version: 0.2.0 +version: 0.2.1 homepage: https://github.com/RZEROSTERN/editorjs-flutter environment: