Skip to content
Merged
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/editorjs_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
21 changes: 21 additions & 0 deletions lib/src/data/mappers/attaches_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import '../../domain/entities/blocks/attaches_block.dart';
import 'block_mapper.dart';

class AttachesMapper implements BlockMapper<AttachesBlock> {
const AttachesMapper();

@override
String get supportedType => 'attaches';

@override
AttachesBlock fromJson(Map<String, dynamic> data) {
final file = data['file'] as Map<String, dynamic>?;
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?,
);
}
}
19 changes: 19 additions & 0 deletions lib/src/data/mappers/embed_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import '../../domain/entities/blocks/embed_block.dart';
import 'block_mapper.dart';

class EmbedMapper implements BlockMapper<EmbedBlock> {
const EmbedMapper();

@override
String get supportedType => 'embed';

@override
EmbedBlock fromJson(Map<String, dynamic> 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?,
);
}
28 changes: 28 additions & 0 deletions lib/src/data/mappers/link_tool_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import '../../domain/entities/blocks/link_tool_block.dart';
import 'block_mapper.dart';

class LinkToolMapper implements BlockMapper<LinkToolBlock> {
const LinkToolMapper();

@override
String get supportedType => 'linkTool';

@override
LinkToolBlock fromJson(Map<String, dynamic> data) {
final rawMeta = data['meta'] as Map<String, dynamic>?;
final rawImage = rawMeta?['image'] as Map<String, dynamic>?;

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,
);
}
}
13 changes: 13 additions & 0 deletions lib/src/data/mappers/raw_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import '../../domain/entities/blocks/raw_block.dart';
import 'block_mapper.dart';

class RawMapper implements BlockMapper<RawBlock> {
const RawMapper();

@override
String get supportedType => 'raw';

@override
RawBlock fromJson(Map<String, dynamic> data) =>
RawBlock(html: (data['html'] as String?) ?? '');
}
8 changes: 8 additions & 0 deletions lib/src/data/registry/block_type_registry.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions lib/src/domain/entities/blocks/attaches_block.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toJson() => {
'file': {
'url': url,
if (name != null) 'name': name,
if (extension != null) 'extension': extension,
if (size != null) 'size': size,
},
'title': title ?? '',
};
}
39 changes: 39 additions & 0 deletions lib/src/domain/entities/blocks/embed_block.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toJson() => {
'service': service,
'source': source,
'embed': embed,
if (width != null) 'width': width,
if (height != null) 'height': height,
'caption': caption ?? '',
};
}
31 changes: 31 additions & 0 deletions lib/src/domain/entities/blocks/link_tool_block.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'link': link,
if (meta != null) 'meta': meta!.toJson(),
};
}
14 changes: 14 additions & 0 deletions lib/src/domain/entities/blocks/raw_block.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toJson() => {'html': html};
}
97 changes: 97 additions & 0 deletions lib/src/presentation/blocks/attaches/attaches_renderer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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<AttachesBlock> {
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
? () => launchUrl(Uri.parse(block.url),
mode: LaunchMode.externalApplication)
Comment thread
RZEROSTERN marked this conversation as resolved.
Outdated
: null,
Comment thread
RZEROSTERN marked this conversation as resolved.
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';
}
}
Loading
Loading