From 4f7aba09ba7e97f06c27b6ebe9a074d265af517c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Rami=CC=81rez?= Date: Wed, 25 Mar 2026 15:01:15 -0600 Subject: [PATCH 1/3] Chore for CI/CD, code coverage and example app --- .github/workflows/ci.yml | 39 + CHANGELOG.md | 27 + CLAUDE.md | 45 +- coverage/lcov.info | 1688 +++++++++++++++++ example/lib/main.dart | 174 ++ example/pubspec.lock | 364 ++++ example/pubspec.yaml | 13 + .../controller/editor_controller.dart | 18 + pubspec.yaml | 9 +- test/unit/block_entities_test.dart | 461 +++++ test/unit/editor_controller_test.dart | 68 + test/unit/html_style_builder_test.dart | 86 + test/widget/block_editors_test.dart | 385 ++++ test/widget/editorjs_editor_test.dart | 209 ++ test/widget/editorjs_view_test.dart | 220 +++ test/widget/renderers_test.dart | 239 +++ test/widget/toolbar_test.dart | 261 +++ 17 files changed, 4289 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 coverage/lcov.info create mode 100644 example/lib/main.dart create mode 100644 example/pubspec.lock create mode 100644 example/pubspec.yaml create mode 100644 test/unit/block_entities_test.dart create mode 100644 test/unit/html_style_builder_test.dart create mode 100644 test/widget/block_editors_test.dart create mode 100644 test/widget/editorjs_editor_test.dart create mode 100644 test/widget/editorjs_view_test.dart create mode 100644 test/widget/toolbar_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa66287 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Test with coverage + run: flutter test --coverage + + - name: Check coverage ≥ 80 % + uses: VeryGoodOpenSource/very_good_coverage@v3 + with: + path: coverage/lcov.info + min_coverage: 80 + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage/lcov.info + if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 4037924..6d507ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## [0.5.0] - 2026-03-25 — Publication readiness + +### New API +* `EditorController.fromJson(String jsonString, {BlockTypeRegistry? typeRegistry})` — creates a controller pre-populated from an existing EditorJS JSON string; gracefully handles invalid JSON. + +### Package metadata +* `pubspec.yaml` — updated description and added `topics` (editor, rich-text, editorjs, content). +* `example/` directory added — two-tab demo app showing `EditorJSView` (viewer tab) and `EditorJSEditor` (editor tab). + +### CI / CD +* `.github/workflows/ci.yml` added — runs `flutter analyze` + `flutter test --coverage` on every PR and push to `master`; enforces ≥ 80 % line coverage via `VeryGoodOpenSource/very_good_coverage@v3`; uploads report to Codecov. + +### Contribution rules +* 80 % minimum test coverage enforced in CI — PRs that drop below this threshold cannot be merged. +* `CLAUDE.md` updated with contribution rules, refreshed block types table, and updated backlog. + +### Tests +* Added `test/unit/block_entities_test.dart` — `toJson()` and `type` getter for all 14 block entities. +* Added `test/unit/html_style_builder_test.dart` — `HtmlStyleBuilder.build()` with various configs. +* Extended `test/widget/renderers_test.dart` — paragraph, quote, list (flat/ordered/nested), table, image, linkTool, raw. +* Added `test/widget/editorjs_view_test.dart` — integration tests for the full parse → render pipeline. +* Added `test/widget/editorjs_editor_test.dart` — editor + block controls + `fromJson` round-trip. +* Added `test/widget/toolbar_test.dart` — toolbar button interactions and hyperlink dialog. +* Added `test/widget/block_editors_test.dart` — individual editor widgets (paragraph, list, checklist, table, image, header, quote, warning, code). + +--- + ## [0.4.0] - 2026-03-25 — Phase 4: Quality & Safety ### Null-safety hardening diff --git a/CLAUDE.md b/CLAUDE.md index a04510d..4e92a59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,15 +109,20 @@ final config = EditorConfig( | Block | Viewer | Editor | Notes | |---|---|---|---| -| `header` | H1–H6 | Yes | | -| `paragraph` | HTML via flutter_html | Yes | | -| `list` | Flat ordered/unordered | Yes | Nested lists not yet supported | -| `delimiter` | Divider | Yes | | -| `image` | Network URL | Yes | caption/border/stretch not yet rendered | -| `quote` | Planned | Planned | | -| `code` | Planned | Planned | | -| `table` | Planned | Planned | | -| `checklist` | Planned | Planned | | +| `header` | H1–H6 | ✅ | | +| `paragraph` | HTML via flutter_html | ✅ | Inline format bar (B/I/U/S/code/mark) | +| `list` | Ordered / unordered / nested | ✅ | | +| `delimiter` | Divider | ✅ | | +| `image` | Network URL | ✅ | caption/border/stretch parsed; image_picker not wired | +| `quote` | With alignment | ✅ | | +| `code` | Monospace + copy button | ✅ | | +| `checklist` | Checked / unchecked | ✅ | | +| `table` | With optional heading row | ✅ | | +| `warning` | Title + message | ✅ | | +| `embed` | Tappable card | Viewer only | | +| `linkTool` | Link preview card | Viewer only | | +| `attaches` | File download card | Viewer only | | +| `raw` | Sanitized HTML | Viewer only | | --- @@ -159,6 +164,15 @@ When building new features, always work bottom-up: - `demo/lib/createnote.dart` — editor usage example - `demo/test_data/` — sample EditorJS JSON and styles JSON +## Contribution Rules + +1. All new code must be covered by unit or widget tests. +2. **Minimum test coverage: 80 %** — the CI workflow enforces this via `VeryGoodOpenSource/very_good_coverage`. PRs that drop coverage below 80 % will fail CI and cannot be merged. +3. Run `fvm flutter analyze` locally before pushing — zero issues required. +4. Run `fvm flutter test --coverage` locally and verify the coverage threshold. +5. Follow the Clean Architecture layer rules: no Flutter imports in `domain/`, no direct widget dependencies in `data/`. +6. New block types must be added bottom-up: entity → mapper → renderer → (editor) → register in both registries → export from barrel. + ## Git Workflow **This repo has branch protection. Direct commits to `master` are not allowed.** @@ -216,7 +230,7 @@ When building new features, always work bottom-up: ## Versioning -Current version: `0.1.0` (in `pubspec.yaml`). +Current version: `0.5.0` (in `pubspec.yaml`). When making changes, always update `CHANGELOG.md` with a new entry at the top following the existing format: - Bump the patch version for bug fixes @@ -225,8 +239,9 @@ When making changes, always update `CHANGELOG.md` with a new entry at the top fo ## Known Issues / Backlog -- Image `caption`, `withBorder`, `stretched`, `withBackground` fields parsed but not rendered -- Nested lists not supported -- No HTML sanitization before passing to `flutter_html` (XSS risk with untrusted JSON) -- All tests are commented out — need full test coverage -- `quote`, `code`, `table`, `checklist` blocks not yet implemented +- `image_picker` is declared as a dependency but `ImageEditor` only accepts a URL string — picker not wired up +- No `EditorController` content persistence to local storage (save/load) +- No inline link editor in paragraph (currently inserts a `` paragraph block via dialog) +- No drag-to-reorder for blocks (only up/down buttons) +- Typing history not tracked by undo/redo (only structural operations are undoable) +- pub.dev publication pending — description, topics, and example are ready but package has not been published yet diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 0000000..ba63f91 --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,1688 @@ +SF:lib/src/data/utils/html_sanitizer.dart +DA:7,0 +DA:9,9 +DA:15,9 +DA:20,9 +DA:27,3 +DA:28,6 +DA:29,6 +DA:30,6 +LF:8 +LH:7 +end_of_record +SF:lib/src/presentation/widgets/editorjs_view.dart +DA:20,1 +DA:24,1 +DA:26,1 +DA:28,3 +DA:29,3 +DA:31,1 +DA:33,1 +DA:34,2 +DA:35,3 +DA:37,2 +LF:10 +LH:10 +end_of_record +SF:lib/src/presentation/widgets/editorjs_editor.dart +DA:25,1 +DA:29,1 +DA:31,1 +DA:32,1 +DA:36,1 +DA:38,1 +DA:39,4 +DA:42,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:51,0 +DA:53,1 +DA:55,4 +DA:56,1 +DA:59,1 +DA:61,3 +DA:63,3 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:71,1 +DA:72,1 +DA:73,4 +DA:75,0 +DA:77,0 +DA:79,1 +DA:80,2 +DA:82,1 +DA:83,1 +DA:84,0 +DA:86,3 +DA:87,0 +DA:89,0 +DA:96,1 +DA:97,2 +DA:98,3 +DA:111,1 +DA:118,1 +DA:120,1 +DA:122,1 +DA:123,2 +DA:124,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:132,1 +DA:135,1 +DA:137,1 +DA:140,1 +DA:141,1 +DA:156,1 +DA:163,1 +DA:165,1 +DA:166,1 +DA:167,2 +DA:169,1 +DA:170,1 +DA:171,1 +DA:174,1 +DA:175,1 +DA:176,1 +DA:177,1 +DA:178,2 +DA:189,0 +DA:191,0 +DA:193,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +LF:74 +LH:56 +end_of_record +SF:lib/src/presentation/controller/editor_controller.dart +DA:14,3 +DA:39,3 +DA:43,3 +DA:44,6 +DA:45,3 +DA:55,2 +DA:59,2 +DA:60,2 +DA:61,4 +DA:62,2 +DA:63,2 +DA:64,2 +DA:68,3 +DA:71,6 +DA:72,3 +DA:75,12 +DA:76,9 +DA:78,3 +DA:86,12 +DA:93,9 +DA:96,18 +DA:104,3 +DA:105,15 +DA:106,6 +DA:108,9 +DA:109,6 +DA:110,6 +DA:112,9 +DA:113,0 +DA:115,12 +DA:119,2 +DA:120,2 +DA:121,4 +DA:122,6 +DA:123,2 +DA:124,2 +DA:125,4 +DA:126,2 +DA:127,2 +DA:128,4 +DA:129,2 +DA:133,1 +DA:134,1 +DA:135,2 +DA:136,3 +DA:137,1 +DA:138,1 +DA:139,2 +DA:140,1 +DA:141,1 +DA:142,2 +DA:143,1 +DA:151,9 +DA:158,3 +DA:160,9 +DA:162,2 +DA:163,4 +DA:164,6 +DA:165,2 +DA:166,2 +DA:169,1 +DA:170,2 +DA:171,3 +DA:172,1 +DA:173,1 +DA:182,1 +DA:183,5 +DA:184,2 +DA:185,1 +DA:188,2 +DA:189,10 +DA:190,4 +DA:191,4 +DA:192,2 +DA:193,2 +DA:196,1 +DA:197,5 +DA:198,5 +DA:199,2 +DA:200,2 +DA:201,2 +DA:202,2 +DA:203,1 +DA:204,1 +DA:207,1 +DA:208,2 +DA:209,2 +DA:210,1 +DA:211,1 +DA:215,1 +DA:216,1 +DA:217,2 +DA:219,1 +DA:221,2 +LF:94 +LH:93 +end_of_record +SF:lib/src/presentation/config/editor_config.dart +DA:14,2 +DA:18,2 +DA:19,2 +LF:3 +LH:3 +end_of_record +SF:lib/src/data/registry/block_type_registry.dart +DA:25,5 +DA:26,5 +DA:27,5 +DA:28,5 +DA:29,5 +DA:30,5 +DA:31,5 +DA:32,5 +DA:33,5 +DA:34,5 +DA:35,5 +DA:36,5 +DA:37,5 +DA:38,5 +DA:39,5 +DA:43,5 +DA:44,15 +DA:49,4 +DA:50,12 +DA:53,0 +LF:20 +LH:19 +end_of_record +SF:lib/src/presentation/registry/block_renderer_registry.dart +DA:62,3 +DA:63,3 +DA:66,3 +DA:67,3 +DA:68,2 +DA:69,3 +DA:70,2 +DA:71,3 +DA:72,2 +DA:73,3 +DA:74,2 +DA:75,3 +DA:76,0 +DA:77,3 +DA:78,2 +DA:79,3 +DA:80,2 +DA:81,3 +DA:82,2 +DA:83,3 +DA:84,2 +DA:85,3 +DA:86,2 +DA:87,3 +DA:88,2 +DA:89,3 +DA:90,0 +DA:91,3 +DA:92,2 +DA:93,3 +DA:94,0 +DA:96,3 +DA:97,2 +DA:98,3 +DA:99,2 +DA:100,3 +DA:101,2 +DA:102,3 +DA:103,2 +DA:104,3 +DA:105,0 +DA:106,3 +DA:107,2 +DA:108,3 +DA:109,2 +DA:110,3 +DA:111,2 +DA:112,3 +DA:113,2 +DA:114,3 +DA:115,2 +DA:118,3 +DA:119,6 +DA:122,3 +DA:123,6 +DA:127,1 +DA:128,4 +DA:132,1 +DA:133,4 +DA:136,0 +DA:137,0 +LF:61 +LH:55 +end_of_record +SF:lib/src/presentation/blocks/base_block_renderer.dart +DA:14,1 +LF:1 +LH:1 +end_of_record +SF:lib/src/presentation/blocks/base_block_editor.dart +DA:16,2 +LF:1 +LH:1 +end_of_record +SF:lib/src/domain/entities/block_entity.dart +DA:2,10 +LF:1 +LH:1 +end_of_record +SF:lib/src/domain/entities/block_document.dart +DA:8,5 +DA:14,6 +DA:15,3 +DA:16,3 +DA:17,3 +DA:18,15 +DA:19,3 +LF:7 +LH:7 +end_of_record +SF:lib/src/domain/entities/style_config.dart +DA:5,1 +DA:17,1 +LF:2 +LH:2 +end_of_record +SF:lib/src/domain/entities/blocks/header_block.dart +DA:10,7 +DA:15,5 +DA:18,3 +DA:19,3 +DA:20,3 +DA:21,3 +LF:6 +LH:6 +end_of_record +SF:lib/src/domain/entities/blocks/paragraph_block.dart +DA:7,9 +DA:9,5 +DA:12,3 +DA:13,6 +LF:4 +LH:4 +end_of_record +SF:lib/src/domain/entities/blocks/list_block.dart +DA:12,7 +DA:14,2 +DA:15,0 +DA:16,1 +DA:19,2 +DA:20,1 +DA:21,5 +DA:29,5 +DA:34,3 +DA:37,1 +DA:38,1 +DA:39,2 +DA:40,5 +LF:13 +LH:12 +end_of_record +SF:lib/src/domain/entities/blocks/delimiter_block.dart +DA:4,6 +DA:6,3 +DA:9,1 +DA:10,1 +LF:4 +LH:4 +end_of_record +SF:lib/src/domain/entities/blocks/image_block.dart +DA:10,6 +DA:18,1 +DA:21,1 +DA:22,1 +DA:23,2 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +LF:9 +LH:9 +end_of_record +SF:lib/src/domain/entities/blocks/quote_block.dart +DA:14,7 +DA:20,3 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,2 +LF:7 +LH:7 +end_of_record +SF:lib/src/domain/entities/blocks/code_block.dart +DA:7,7 +DA:9,3 +DA:12,1 +DA:13,2 +LF:4 +LH:4 +end_of_record +SF:lib/src/domain/entities/blocks/checklist_block.dart +DA:7,8 +DA:9,4 +DA:10,2 +DA:11,0 +DA:14,4 +DA:20,8 +DA:22,3 +DA:25,1 +DA:26,1 +DA:27,5 +LF:10 +LH:9 +end_of_record +SF:lib/src/domain/entities/blocks/table_block.dart +DA:10,5 +DA:15,3 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +LF:6 +LH:6 +end_of_record +SF:lib/src/domain/entities/blocks/warning_block.dart +DA:7,7 +DA:9,3 +DA:12,1 +DA:13,3 +LF:4 +LH:4 +end_of_record +SF:lib/src/domain/entities/blocks/embed_block.dart +DA:18,3 +DA:27,2 +DA:30,1 +DA:31,1 +DA:32,2 +DA:33,2 +DA:34,2 +DA:35,3 +DA:36,3 +DA:37,2 +LF:10 +LH:10 +end_of_record +SF:lib/src/domain/entities/blocks/link_tool_block.dart +DA:8,2 +DA:10,2 +DA:11,3 +DA:12,3 +DA:13,4 +DA:21,2 +DA:23,1 +DA:26,1 +DA:27,1 +DA:28,2 +DA:29,4 +LF:11 +LH:11 +end_of_record +SF:lib/src/domain/entities/blocks/attaches_block.dart +DA:13,3 +DA:21,2 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,2 +DA:28,1 +DA:29,3 +DA:30,3 +DA:32,1 +LF:10 +LH:10 +end_of_record +SF:lib/src/domain/entities/blocks/raw_block.dart +DA:7,2 +DA:9,1 +DA:12,1 +DA:13,2 +LF:4 +LH:4 +end_of_record +SF:lib/src/data/datasources/json_document_source.dart +DA:13,5 +DA:15,4 +DA:19,4 +DA:20,3 +DA:21,3 +DA:22,6 +DA:26,0 +DA:27,0 +DA:28,0 +DA:35,13 +DA:38,4 +DA:39,8 +DA:40,12 +DA:41,8 +DA:42,4 +DA:43,0 +DA:44,8 +DA:45,3 +DA:46,3 +DA:47,3 +DA:53,4 +DA:54,4 +DA:56,4 +DA:57,10 +DA:58,4 +DA:63,2 +DA:64,4 +LF:27 +LH:23 +end_of_record +SF:lib/src/data/mappers/attaches_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:12,6 +DA:13,2 +DA:14,6 +DA:15,6 +DA:16,4 +DA:17,1 +DA:19,2 +DA:20,2 +DA:21,3 +DA:24,6 +LF:13 +LH:13 +end_of_record +SF:lib/src/data/mappers/checklist_mapper.dart +DA:5,6 +DA:7,5 +DA:10,3 +DA:13,10 +DA:14,3 +DA:15,9 +DA:16,3 +DA:17,9 +DA:18,9 +DA:20,3 +LF:10 +LH:10 +end_of_record +SF:lib/src/data/mappers/code_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:12,4 +LF:4 +LH:4 +end_of_record +SF:lib/src/data/mappers/delimiter_mapper.dart +DA:5,6 +DA:7,5 +DA:10,4 +LF:3 +LH:3 +end_of_record +SF:lib/src/data/mappers/embed_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:11,2 +DA:12,6 +DA:13,6 +DA:14,6 +DA:15,2 +DA:16,2 +DA:17,3 +DA:20,2 +DA:21,2 +DA:22,3 +DA:26,5 +LF:14 +LH:14 +end_of_record +SF:lib/src/data/mappers/header_mapper.dart +DA:5,6 +DA:7,5 +DA:10,5 +DA:11,5 +DA:12,10 +DA:13,5 +DA:14,1 +DA:15,15 +LF:8 +LH:8 +end_of_record +SF:lib/src/data/mappers/image_mapper.dart +DA:5,6 +DA:7,5 +DA:10,1 +DA:12,3 +DA:13,1 +DA:14,3 +DA:15,3 +DA:16,3 +DA:17,3 +DA:18,3 +LF:10 +LH:10 +end_of_record +SF:lib/src/data/mappers/link_tool_mapper.dart +DA:5,6 +DA:7,5 +DA:10,1 +DA:12,1 +DA:13,3 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +DA:24,1 +LF:11 +LH:11 +end_of_record +SF:lib/src/data/mappers/list_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:12,2 +DA:13,3 +DA:15,2 +DA:16,2 +DA:17,6 +DA:23,2 +DA:24,2 +DA:25,1 +DA:27,2 +DA:28,2 +DA:29,2 +DA:30,2 +DA:31,6 +DA:34,0 +LF:17 +LH:16 +end_of_record +SF:lib/src/data/mappers/paragraph_mapper.dart +DA:5,6 +DA:7,5 +DA:10,4 +DA:11,4 +DA:12,4 +LF:5 +LH:5 +end_of_record +SF:lib/src/data/mappers/quote_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:12,2 +DA:13,2 +DA:14,2 +DA:15,2 +DA:16,2 +DA:17,6 +DA:18,1 +LF:10 +LH:10 +end_of_record +SF:lib/src/data/mappers/raw_mapper.dart +DA:5,6 +DA:7,5 +DA:10,1 +DA:12,2 +LF:4 +LH:4 +end_of_record +SF:lib/src/data/mappers/table_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:13,7 +DA:14,2 +DA:15,6 +DA:16,4 +DA:17,2 +DA:18,8 +DA:19,2 +LF:10 +LH:10 +end_of_record +SF:lib/src/data/mappers/warning_mapper.dart +DA:5,6 +DA:7,5 +DA:10,2 +DA:11,2 +DA:12,2 +DA:13,2 +LF:6 +LH:6 +end_of_record +SF:lib/src/domain/usecases/parse_document.dart +DA:7,3 +DA:9,9 +LF:2 +LH:2 +end_of_record +SF:lib/src/domain/usecases/serialize_document.dart +DA:7,3 +DA:9,3 +LF:2 +LH:2 +end_of_record +SF:lib/src/presentation/blocks/attaches/attaches_renderer.dart +DA:8,1 +DA:11,2 +DA:14,6 +DA:15,5 +DA:17,2 +DA:18,6 +DA:19,0 +DA:20,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:29,2 +DA:30,2 +DA:32,2 +DA:33,4 +DA:34,2 +DA:35,2 +DA:37,2 +DA:38,2 +DA:39,2 +DA:41,2 +DA:42,2 +DA:43,2 +DA:45,2 +DA:46,6 +DA:47,2 +DA:52,2 +DA:53,2 +DA:55,2 +DA:56,2 +DA:58,2 +DA:61,2 +DA:66,4 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,2 +DA:71,1 +DA:72,1 +DA:74,1 +DA:75,1 +DA:81,2 +DA:82,2 +DA:89,3 +DA:90,2 +DA:91,4 +DA:92,4 +DA:93,6 +DA:94,6 +DA:95,6 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +LF:55 +LH:45 +end_of_record +SF:lib/src/presentation/blocks/checklist/checklist_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:17,2 +DA:19,2 +DA:20,10 +DA:21,2 +DA:22,12 +DA:25,2 +DA:27,4 +DA:28,2 +DA:30,2 +DA:33,1 +DA:34,6 +DA:36,1 +DA:37,2 +DA:38,8 +DA:40,1 +DA:43,1 +DA:44,2 +DA:45,2 +DA:46,3 +DA:48,1 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:60,2 +DA:62,2 +DA:64,2 +DA:65,8 +DA:66,2 +DA:67,2 +DA:68,2 +DA:69,6 +DA:70,2 +DA:72,2 +DA:73,2 +DA:74,4 +DA:75,0 +DA:76,0 +DA:77,0 +DA:85,2 +DA:87,6 +DA:91,2 +DA:92,2 +LF:48 +LH:39 +end_of_record +SF:lib/src/presentation/blocks/checklist/checklist_renderer.dart +DA:7,1 +DA:9,2 +DA:11,2 +DA:13,8 +DA:14,2 +DA:16,2 +DA:17,2 +DA:19,2 +DA:20,2 +DA:24,2 +DA:25,6 +DA:29,2 +DA:30,2 +DA:31,2 +DA:32,2 +DA:33,2 +DA:35,2 +DA:36,2 +DA:42,2 +LF:19 +LH:19 +end_of_record +SF:lib/src/presentation/blocks/code/code_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:16,2 +DA:18,2 +DA:19,10 +DA:22,2 +DA:24,4 +DA:25,2 +DA:28,2 +DA:30,2 +DA:32,2 +DA:34,2 +DA:37,2 +DA:38,2 +DA:39,0 +LF:16 +LH:15 +end_of_record +SF:lib/src/presentation/blocks/code/code_renderer.dart +DA:8,1 +DA:10,2 +DA:12,2 +DA:14,2 +DA:16,2 +DA:18,2 +DA:19,2 +DA:20,2 +DA:23,2 +DA:24,4 +DA:33,2 +DA:36,2 +DA:39,0 +LF:13 +LH:12 +end_of_record +SF:lib/src/presentation/blocks/delimiter/delimiter_editor.dart +DA:7,1 +DA:13,1 +DA:14,1 +DA:18,1 +LF:4 +LH:4 +end_of_record +SF:lib/src/presentation/blocks/delimiter/delimiter_renderer.dart +DA:7,1 +DA:9,2 +LF:2 +LH:2 +end_of_record +SF:lib/src/presentation/blocks/embed/embed_renderer.dart +DA:8,1 +DA:10,2 +DA:12,2 +DA:13,6 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:27,2 +DA:28,2 +DA:29,2 +DA:30,4 +DA:31,2 +DA:32,2 +DA:34,2 +DA:36,2 +DA:38,2 +DA:42,2 +DA:43,2 +DA:48,2 +DA:49,2 +DA:53,2 +DA:54,6 +DA:55,6 +DA:57,2 +DA:61,2 +DA:71,2 +DA:74,2 +DA:75,4 +DA:76,2 +DA:78,2 +DA:80,2 +DA:87,4 +DA:88,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:94,0 +DA:96,0 +DA:106,2 +DA:107,10 +LF:43 +LH:31 +end_of_record +SF:lib/src/presentation/blocks/header/header_editor.dart +DA:7,2 +DA:13,2 +DA:14,2 +DA:20,2 +DA:22,2 +DA:23,10 +DA:26,2 +DA:28,4 +DA:29,2 +DA:32,0 +DA:33,0 +DA:36,2 +DA:38,6 +DA:39,2 +DA:40,2 +DA:41,0 +DA:42,0 +DA:43,0 +DA:47,2 +DA:49,2 +DA:50,2 +DA:51,2 +DA:52,2 +DA:54,8 +DA:56,2 +DA:57,8 +LF:26 +LH:21 +end_of_record +SF:lib/src/presentation/blocks/header/header_renderer.dart +DA:7,1 +DA:9,2 +DA:11,2 +DA:13,2 +DA:14,4 +DA:15,2 +DA:16,6 +DA:17,6 +DA:18,2 +DA:24,2 +DA:25,2 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:33,2 +DA:34,2 +LF:17 +LH:17 +end_of_record +SF:lib/src/presentation/blocks/image/image_editor.dart +DA:10,1 +DA:16,1 +DA:17,1 +DA:24,0 +DA:25,0 +DA:27,0 +DA:29,0 +DA:32,1 +DA:34,5 +DA:36,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,3 +DA:45,1 +DA:48,1 +DA:49,1 +DA:50,1 +DA:51,0 +DA:55,1 +DA:56,0 +DA:68,6 +DA:70,1 +DA:72,1 +DA:74,1 +LF:26 +LH:20 +end_of_record +SF:lib/src/presentation/blocks/image/image_renderer.dart +DA:7,0 +DA:9,1 +DA:11,1 +DA:12,2 +DA:13,2 +DA:14,1 +DA:17,2 +DA:18,0 +DA:23,2 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:33,2 +DA:34,0 +DA:35,0 +DA:36,0 +DA:43,1 +DA:45,1 +DA:47,5 +DA:48,1 +DA:50,1 +DA:51,2 +DA:52,1 +DA:54,1 +DA:56,1 +DA:66,6 +DA:68,1 +DA:70,1 +DA:72,1 +LF:30 +LH:21 +end_of_record +SF:lib/src/presentation/blocks/link_tool/link_tool_renderer.dart +DA:8,0 +DA:11,1 +DA:13,2 +DA:15,1 +DA:16,3 +DA:17,0 +DA:18,0 +DA:22,0 +DA:25,0 +DA:26,0 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,2 +DA:37,1 +DA:39,1 +DA:41,1 +DA:43,1 +DA:44,0 +DA:47,0 +DA:48,0 +DA:52,0 +DA:57,1 +DA:59,1 +DA:60,1 +DA:62,1 +DA:64,1 +DA:65,3 +DA:66,1 +DA:67,1 +DA:68,1 +DA:71,1 +DA:76,1 +DA:77,2 +DA:78,1 +DA:80,1 +DA:81,1 +DA:82,1 +DA:84,1 +DA:85,1 +DA:91,1 +DA:93,1 +DA:94,2 +DA:95,1 +DA:97,1 +DA:98,1 +DA:116,6 +DA:118,1 +DA:120,1 +DA:123,1 +DA:124,1 +LF:51 +LH:41 +end_of_record +SF:lib/src/presentation/blocks/list/list_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:17,2 +DA:19,2 +DA:20,10 +DA:21,2 +DA:22,12 +DA:25,2 +DA:27,4 +DA:28,2 +DA:30,2 +DA:33,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:44,0 +DA:45,0 +DA:47,0 +DA:49,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:58,0 +DA:63,1 +DA:64,4 +DA:65,3 +DA:66,2 +DA:70,1 +DA:71,2 +DA:72,2 +DA:73,3 +DA:75,1 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:84,0 +DA:87,2 +DA:89,2 +DA:91,2 +DA:92,8 +DA:93,2 +DA:94,2 +DA:95,2 +DA:97,2 +DA:98,8 +DA:99,1 +DA:100,1 +DA:104,2 +DA:105,2 +DA:106,4 +DA:107,0 +DA:108,0 +DA:109,0 +DA:114,2 +DA:116,6 +DA:120,2 +DA:121,2 +LF:64 +LH:39 +end_of_record +SF:lib/src/presentation/blocks/list/list_renderer.dart +DA:11,1 +DA:13,2 +DA:15,2 +DA:17,12 +DA:21,2 +DA:27,2 +DA:30,2 +DA:31,4 +DA:32,2 +DA:34,2 +DA:35,2 +DA:36,2 +DA:38,2 +DA:39,4 +DA:40,2 +DA:42,0 +DA:46,2 +DA:47,2 +DA:48,4 +DA:49,2 +DA:54,4 +DA:55,3 +LF:22 +LH:21 +end_of_record +SF:lib/src/presentation/utils/html_style_builder.dart +DA:11,0 +DA:17,3 +DA:18,3 +DA:20,1 +DA:21,3 +DA:24,8 +DA:25,3 +DA:26,1 +DA:27,2 +DA:29,3 +DA:31,3 +DA:32,1 +DA:39,1 +DA:41,1 +DA:45,2 +DA:47,1 +DA:48,2 +DA:55,1 +DA:61,1 +LF:19 +LH:18 +end_of_record +SF:lib/src/presentation/blocks/paragraph/paragraph_editor.dart +DA:7,2 +DA:13,2 +DA:14,2 +DA:22,2 +DA:24,2 +DA:25,10 +DA:26,4 +DA:27,3 +DA:28,5 +DA:32,2 +DA:34,4 +DA:35,4 +DA:36,2 +DA:39,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:51,1 +DA:52,4 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:62,0 +DA:64,0 +DA:65,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:72,0 +DA:74,0 +DA:76,0 +DA:79,2 +DA:81,2 +DA:83,2 +DA:85,2 +DA:86,2 +DA:87,2 +DA:88,2 +DA:89,2 +DA:90,2 +DA:91,2 +DA:108,1 +DA:110,1 +DA:112,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:120,0 +DA:122,1 +DA:126,0 +DA:128,1 +DA:132,0 +DA:134,1 +DA:138,0 +DA:140,1 +DA:144,0 +DA:146,1 +DA:150,0 +DA:169,1 +DA:181,1 +DA:183,1 +DA:184,1 +DA:185,1 +DA:186,1 +DA:187,1 +DA:188,1 +DA:192,1 +DA:193,1 +DA:194,1 +DA:195,1 +DA:198,1 +DA:199,1 +DA:200,1 +DA:201,1 +DA:203,1 +DA:204,1 +DA:205,1 +DA:207,1 +DA:210,1 +LF:82 +LH:57 +end_of_record +SF:lib/src/presentation/blocks/paragraph/paragraph_renderer.dart +DA:10,1 +DA:12,2 +DA:14,2 +DA:15,6 +DA:16,4 +LF:5 +LH:5 +end_of_record +SF:lib/src/presentation/blocks/quote/quote_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:18,2 +DA:20,2 +DA:21,10 +DA:22,10 +DA:23,8 +DA:26,2 +DA:28,4 +DA:29,4 +DA:30,2 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:39,2 +DA:41,2 +DA:43,2 +DA:44,2 +DA:45,2 +DA:46,6 +DA:51,2 +DA:53,2 +DA:54,2 +DA:55,2 +DA:56,0 +DA:64,2 +DA:65,2 +DA:66,0 +DA:72,4 +DA:74,2 +DA:75,2 +DA:78,2 +DA:79,2 +DA:81,2 +DA:82,4 +DA:83,4 +DA:84,0 +DA:85,0 +DA:86,0 +LF:41 +LH:32 +end_of_record +SF:lib/src/presentation/blocks/quote/quote_renderer.dart +DA:10,1 +DA:12,2 +DA:14,4 +DA:15,2 +DA:16,2 +DA:17,2 +DA:20,4 +DA:21,4 +DA:22,2 +DA:24,2 +DA:26,2 +DA:28,2 +DA:31,2 +DA:33,2 +DA:34,2 +DA:35,2 +DA:36,6 +DA:40,8 +DA:42,2 +DA:43,6 +DA:44,2 +DA:45,2 +DA:46,6 +DA:49,10 +DA:50,2 +DA:52,2 +DA:53,6 +DA:55,2 +DA:57,2 +DA:59,2 +DA:68,2 +DA:69,2 +DA:70,2 +DA:71,2 +LF:34 +LH:34 +end_of_record +SF:lib/src/presentation/blocks/raw/raw_renderer.dart +DA:11,0 +DA:13,1 +DA:15,1 +DA:16,3 +DA:17,2 +LF:5 +LH:4 +end_of_record +SF:lib/src/presentation/blocks/table/table_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:17,2 +DA:19,2 +DA:20,8 +DA:21,12 +DA:23,2 +DA:24,0 +DA:25,8 +DA:26,2 +DA:28,8 +DA:31,2 +DA:33,4 +DA:34,4 +DA:35,2 +DA:38,2 +DA:41,6 +DA:43,14 +DA:45,1 +DA:46,4 +DA:47,1 +DA:48,1 +DA:49,6 +DA:50,1 +DA:54,1 +DA:55,2 +DA:56,2 +DA:57,4 +DA:60,1 +DA:63,1 +DA:64,2 +DA:65,2 +DA:66,2 +DA:69,1 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:78,0 +DA:80,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:93,0 +DA:96,2 +DA:98,2 +DA:100,2 +DA:101,2 +DA:102,2 +DA:103,2 +DA:104,2 +DA:105,1 +DA:106,3 +DA:107,1 +DA:113,2 +DA:115,2 +DA:117,2 +DA:118,8 +DA:119,2 +DA:120,2 +DA:121,10 +DA:122,2 +DA:124,2 +DA:125,6 +DA:126,0 +DA:127,2 +DA:128,4 +DA:132,2 +DA:134,2 +DA:137,4 +DA:144,2 +DA:146,0 +DA:152,2 +DA:153,2 +DA:154,6 +DA:155,2 +DA:157,2 +DA:158,2 +DA:161,0 +DA:171,2 +DA:172,2 +DA:173,2 +DA:174,2 +DA:178,2 +DA:179,2 +LF:91 +LH:72 +end_of_record +SF:lib/src/presentation/blocks/table/table_renderer.dart +DA:7,1 +DA:9,2 +DA:11,6 +DA:13,12 +DA:14,4 +DA:16,2 +DA:18,2 +DA:20,2 +DA:21,4 +DA:22,12 +DA:23,2 +DA:24,2 +DA:25,6 +DA:27,2 +DA:29,4 +DA:31,4 +DA:33,6 +DA:34,2 +DA:37,2 +DA:39,2 +DA:42,2 +DA:48,2 +LF:22 +LH:22 +end_of_record +SF:lib/src/presentation/blocks/warning/warning_editor.dart +DA:7,2 +DA:9,2 +DA:10,2 +DA:17,2 +DA:19,2 +DA:20,10 +DA:21,10 +DA:24,2 +DA:26,4 +DA:27,4 +DA:28,2 +DA:31,0 +DA:32,0 +DA:33,0 +DA:36,2 +DA:38,2 +DA:40,2 +DA:42,2 +DA:43,2 +DA:45,2 +DA:47,2 +DA:53,2 +DA:54,2 +DA:55,2 +DA:56,2 +DA:57,2 +DA:58,0 +DA:66,2 +DA:67,2 +DA:68,0 +LF:30 +LH:25 +end_of_record +SF:lib/src/presentation/blocks/warning/warning_renderer.dart +DA:7,1 +DA:9,2 +DA:11,2 +DA:13,2 +DA:15,2 +DA:16,2 +DA:18,2 +DA:20,2 +DA:26,2 +DA:27,2 +DA:29,2 +DA:30,6 +DA:31,2 +DA:33,2 +DA:34,4 +DA:35,2 +DA:37,2 +DA:41,2 +DA:42,4 +DA:43,4 +LF:20 +LH:20 +end_of_record +SF:lib/src/presentation/widgets/editorjs_toolbar.dart +DA:24,2 +DA:30,2 +DA:32,2 +DA:33,2 +DA:34,2 +DA:35,6 +DA:36,6 +DA:38,2 +DA:40,2 +DA:41,2 +DA:43,2 +DA:46,3 +DA:48,4 +DA:49,2 +DA:50,2 +DA:51,2 +DA:52,1 +DA:53,3 +DA:55,2 +DA:58,1 +DA:59,2 +DA:61,2 +DA:64,3 +DA:67,2 +DA:70,3 +DA:71,1 +DA:73,1 +DA:77,2 +DA:80,3 +DA:81,1 +DA:83,1 +DA:87,2 +DA:90,3 +DA:97,2 +DA:100,3 +DA:101,1 +DA:102,1 +DA:103,1 +DA:104,1 +DA:109,2 +DA:112,3 +DA:114,2 +DA:117,1 +DA:118,2 +DA:121,2 +DA:124,3 +DA:126,2 +DA:129,2 +DA:132,2 +DA:135,6 +DA:137,2 +DA:140,4 +DA:142,2 +DA:145,6 +DA:146,6 +DA:156,1 +DA:157,1 +DA:158,1 +DA:160,1 +DA:162,2 +DA:164,1 +DA:166,1 +DA:167,1 +DA:172,1 +DA:179,1 +DA:180,1 +DA:181,2 +DA:184,1 +DA:185,1 +DA:186,2 +DA:187,2 +DA:188,1 +DA:189,3 +DA:190,2 +DA:193,1 +DA:209,2 +DA:214,2 +DA:216,2 +DA:218,2 +DA:219,4 +DA:220,2 +DA:221,2 +DA:229,2 +DA:230,2 +DA:231,2 +DA:232,2 +DA:233,2 +LF:87 +LH:87 +end_of_record diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..ac71445 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; + +void main() => runApp(const ExampleApp()); + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'EditorJS Flutter Example', + home: _HomeScreen(), + ); + } +} + +class _HomeScreen extends StatefulWidget { + const _HomeScreen(); + + @override + State<_HomeScreen> createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State<_HomeScreen> { + int _selectedIndex = 0; + + static const _sampleJson = ''' + { + "time": 1000, + "version": "2.19.0", + "blocks": [ + { + "id": "1", + "type": "header", + "data": {"text": "Welcome to EditorJS Flutter", "level": 1} + }, + { + "id": "2", + "type": "paragraph", + "data": {"text": "This is a viewer tab showing EditorJS content rendered as Flutter widgets."} + }, + { + "id": "3", + "type": "delimiter", + "data": {} + }, + { + "id": "4", + "type": "quote", + "data": {"text": "Build beautiful editors with Flutter.", "caption": "EditorJS Flutter", "alignment": "left"} + }, + { + "id": "5", + "type": "code", + "data": {"code": "final controller = EditorController();\\nfinal json = controller.getContent();"} + } + ] + } + '''; + + late final EditorController _editorController; + + @override + void initState() { + super.initState(); + _editorController = EditorController( + initialBlocks: const [ + HeaderBlock(text: 'My Document', level: 2), + ParagraphBlock(html: 'Start editing here...'), + ], + ); + } + + @override + void dispose() { + _editorController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('EditorJS Flutter Example'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Row( + children: [ + _TabButton( + label: 'Viewer', + selected: _selectedIndex == 0, + onTap: () => setState(() => _selectedIndex = 0), + ), + _TabButton( + label: 'Editor', + selected: _selectedIndex == 1, + onTap: () => setState(() => _selectedIndex = 1), + ), + ], + ), + ), + ), + body: _selectedIndex == 0 + ? SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: EditorJSView(jsonData: _sampleJson), + ) + : EditorJSEditor(controller: _editorController), + floatingActionButton: _selectedIndex == 1 + ? FloatingActionButton( + onPressed: () { + final json = _editorController.getContent(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + json, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + duration: const Duration(seconds: 4), + ), + ); + }, + child: const Icon(Icons.save), + ) + : null, + ); + } +} + +class _TabButton extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: onTap, + child: Container( + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.grey, + ), + ), + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..5540466 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,364 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + editorjs_flutter: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.5.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_html: + dependency: transitive + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd" + url: "https://pub.dev" + source: hosted + version: "0.8.13+15" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..fc650fb --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,13 @@ +name: editorjs_flutter_example +description: Demonstrates the editorjs_flutter package. +publish_to: 'none' + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + editorjs_flutter: + path: ../ diff --git a/lib/src/presentation/controller/editor_controller.dart b/lib/src/presentation/controller/editor_controller.dart index b1d94b0..f2a3440 100644 --- a/lib/src/presentation/controller/editor_controller.dart +++ b/lib/src/presentation/controller/editor_controller.dart @@ -4,6 +4,7 @@ import '../../data/datasources/json_document_source.dart'; import '../../data/registry/block_type_registry.dart'; import '../../domain/entities/block_document.dart'; import '../../domain/entities/block_entity.dart'; +import '../../domain/usecases/parse_document.dart'; import '../../domain/usecases/serialize_document.dart'; /// A paired snapshot of block data and their stable IDs, used by undo/redo. @@ -47,6 +48,23 @@ class EditorController extends ChangeNotifier { ); } + /// Creates a controller pre-populated with blocks parsed from an EditorJS + /// JSON string. Unknown block types are silently dropped (logged via + /// `dart:developer`). If [jsonString] is empty or invalid JSON the + /// controller starts empty. + factory EditorController.fromJson( + String jsonString, { + BlockTypeRegistry? typeRegistry, + }) { + final registry = typeRegistry ?? BlockTypeRegistry(); + final source = JsonDocumentSource(registry: registry); + final document = ParseDocument(source)(jsonString); + return EditorController._internal( + initialBlocks: document.blocks, + serializer: SerializeDocument(source), + ); + } + EditorController._internal({ List? initialBlocks, required SerializeDocument serializer, diff --git a/pubspec.yaml b/pubspec.yaml index 0df0acb..1323fbf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,12 @@ name: editorjs_flutter -description: A new Flutter package project. -version: 0.4.0 +description: "Flutter viewer and editor widgets for the EditorJS JSON schema. Supports 14 built-in block types with a Clean Architecture extension point for custom blocks." +version: 0.5.0 homepage: https://github.com/RZEROSTERN/editorjs-flutter +topics: + - editor + - rich-text + - editorjs + - content environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/unit/block_entities_test.dart b/test/unit/block_entities_test.dart new file mode 100644 index 0000000..be6fe75 --- /dev/null +++ b/test/unit/block_entities_test.dart @@ -0,0 +1,461 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; + +void main() { + // --------------------------------------------------------------------------- + // HeaderBlock + // --------------------------------------------------------------------------- + group('HeaderBlock', () { + test('type getter returns "header"', () { + const block = HeaderBlock(text: 'Hi', level: 2); + expect(block.type, 'header'); + }); + + test('toJson returns correct map', () { + const block = HeaderBlock(text: 'Hi', level: 2); + final json = block.toJson(); + expect(json['text'], 'Hi'); + expect(json['level'], 2); + }); + }); + + // --------------------------------------------------------------------------- + // ParagraphBlock + // --------------------------------------------------------------------------- + group('ParagraphBlock', () { + test('type getter returns "paragraph"', () { + const block = ParagraphBlock(html: 'x'); + expect(block.type, 'paragraph'); + }); + + test('toJson contains text key', () { + const block = ParagraphBlock(html: 'x'); + final json = block.toJson(); + expect(json['text'], 'x'); + }); + }); + + // --------------------------------------------------------------------------- + // ListBlock + // --------------------------------------------------------------------------- + group('ListBlock', () { + test('type getter returns "list"', () { + const block = ListBlock( + style: ListStyle.ordered, + items: [ListItem(content: 'a')], + ); + expect(block.type, 'list'); + }); + + test('toJson ordered style', () { + const block = ListBlock( + style: ListStyle.ordered, + items: [ListItem(content: 'a')], + ); + final json = block.toJson(); + expect(json['style'], 'ordered'); + final items = json['items'] as List; + expect(items.length, 1); + expect((items[0] as Map)['content'], 'a'); + }); + + test('toJson unordered style', () { + const block = ListBlock( + style: ListStyle.unordered, + items: [ListItem(content: 'b')], + ); + final json = block.toJson(); + expect(json['style'], 'unordered'); + }); + + test('ListItem toJson includes nested items', () { + const item = ListItem( + content: 'parent', + items: [ListItem(content: 'child')], + ); + final json = item.toJson(); + expect(json['content'], 'parent'); + final nested = json['items'] as List; + expect(nested.length, 1); + expect((nested[0] as Map)['content'], 'child'); + }); + + test('ListItem copyWith works', () { + const item = ListItem(content: 'original'); + final copy = item.copyWith(content: 'updated'); + expect(copy.content, 'updated'); + expect(copy.items, isEmpty); + }); + }); + + // --------------------------------------------------------------------------- + // DelimiterBlock + // --------------------------------------------------------------------------- + group('DelimiterBlock', () { + test('type getter returns "delimiter"', () { + const block = DelimiterBlock(); + expect(block.type, 'delimiter'); + }); + + test('toJson returns empty map', () { + const block = DelimiterBlock(); + expect(block.toJson(), isEmpty); + }); + }); + + // --------------------------------------------------------------------------- + // ImageBlock + // --------------------------------------------------------------------------- + group('ImageBlock', () { + test('type getter returns "image"', () { + const block = ImageBlock(url: 'http://x.com/img.png'); + expect(block.type, 'image'); + }); + + test('toJson contains file.url', () { + const block = ImageBlock(url: 'http://x.com/img.png'); + final json = block.toJson(); + final file = json['file'] as Map; + expect(file['url'], 'http://x.com/img.png'); + }); + + test('toJson defaults for bool fields', () { + const block = ImageBlock(url: 'http://x.com/img.png'); + final json = block.toJson(); + expect(json['withBorder'], false); + expect(json['stretched'], false); + expect(json['withBackground'], false); + }); + + test('toJson with all fields set', () { + const block = ImageBlock( + url: 'http://x.com/img.png', + caption: 'Caption', + withBorder: true, + stretched: true, + withBackground: true, + ); + final json = block.toJson(); + expect(json['caption'], 'Caption'); + expect(json['withBorder'], true); + expect(json['stretched'], true); + expect(json['withBackground'], true); + }); + }); + + // --------------------------------------------------------------------------- + // QuoteBlock + // --------------------------------------------------------------------------- + group('QuoteBlock', () { + test('type getter returns "quote"', () { + const block = QuoteBlock(text: 'q', alignment: QuoteAlignment.center); + expect(block.type, 'quote'); + }); + + test('toJson contains text and alignment', () { + const block = QuoteBlock(text: 'q', alignment: QuoteAlignment.center); + final json = block.toJson(); + expect(json['text'], 'q'); + expect(json['alignment'], 'center'); + }); + + test('toJson with caption', () { + const block = QuoteBlock( + text: 'q', + caption: 'Author', + alignment: QuoteAlignment.left, + ); + final json = block.toJson(); + expect(json['caption'], 'Author'); + }); + + test('toJson caption defaults to empty string when null', () { + const block = QuoteBlock(text: 'q', alignment: QuoteAlignment.right); + final json = block.toJson(); + expect(json['caption'], ''); + }); + }); + + // --------------------------------------------------------------------------- + // CodeBlock + // --------------------------------------------------------------------------- + group('CodeBlock', () { + test('type getter returns "code"', () { + const block = CodeBlock(code: 'print(1)'); + expect(block.type, 'code'); + }); + + test('toJson contains code field', () { + const block = CodeBlock(code: 'print(1)'); + final json = block.toJson(); + expect(json['code'], 'print(1)'); + }); + }); + + // --------------------------------------------------------------------------- + // ChecklistBlock + // --------------------------------------------------------------------------- + group('ChecklistBlock', () { + test('type getter returns "checklist"', () { + const block = ChecklistBlock( + items: [ChecklistItem(text: 'do', checked: true)], + ); + expect(block.type, 'checklist'); + }); + + test('toJson contains items list', () { + const block = ChecklistBlock( + items: [ + ChecklistItem(text: 'do', checked: true), + ChecklistItem(text: 'don\'t', checked: false), + ], + ); + final json = block.toJson(); + final items = json['items'] as List; + expect(items.length, 2); + expect((items[0] as Map)['text'], 'do'); + expect((items[0] as Map)['checked'], true); + expect((items[1] as Map)['checked'], false); + }); + + test('ChecklistItem copyWith works', () { + const item = ChecklistItem(text: 'task', checked: false); + final copy = item.copyWith(checked: true); + expect(copy.checked, true); + expect(copy.text, 'task'); + }); + }); + + // --------------------------------------------------------------------------- + // TableBlock + // --------------------------------------------------------------------------- + group('TableBlock', () { + test('type getter returns "table"', () { + const block = TableBlock(content: [ + ['a', 'b'], + ['c', 'd'], + ]); + expect(block.type, 'table'); + }); + + test('toJson with headings', () { + const block = TableBlock( + content: [ + ['a', 'b'], + ['c', 'd'], + ], + withHeadings: true, + ); + final json = block.toJson(); + expect(json['withHeadings'], true); + final content = json['content'] as List; + expect(content.length, 2); + }); + + test('toJson without headings defaults to false', () { + const block = TableBlock(content: [ + ['x'], + ]); + final json = block.toJson(); + expect(json['withHeadings'], false); + }); + }); + + // --------------------------------------------------------------------------- + // WarningBlock + // --------------------------------------------------------------------------- + group('WarningBlock', () { + test('type getter returns "warning"', () { + const block = WarningBlock(title: 'T', message: 'M'); + expect(block.type, 'warning'); + }); + + test('toJson contains title and message', () { + const block = WarningBlock(title: 'T', message: 'M'); + final json = block.toJson(); + expect(json['title'], 'T'); + expect(json['message'], 'M'); + }); + }); + + // --------------------------------------------------------------------------- + // EmbedBlock + // --------------------------------------------------------------------------- + group('EmbedBlock', () { + test('type getter returns "embed"', () { + const block = EmbedBlock( + service: 'youtube', + source: 'https://yt.be/x', + embed: 'https://yt.be/embed/x', + ); + expect(block.type, 'embed'); + }); + + test('toJson contains service, source, embed', () { + const block = EmbedBlock( + service: 'youtube', + source: 'https://yt.be/x', + embed: 'https://yt.be/embed/x', + ); + final json = block.toJson(); + expect(json['service'], 'youtube'); + expect(json['source'], 'https://yt.be/x'); + expect(json['embed'], 'https://yt.be/embed/x'); + }); + + test('toJson optional width and height when set', () { + const block = EmbedBlock( + service: 'vimeo', + source: 'https://vimeo.com/1', + embed: 'https://player.vimeo.com/video/1', + width: 640, + height: 480, + caption: 'Nice video', + ); + final json = block.toJson(); + expect(json['width'], 640); + expect(json['height'], 480); + expect(json['caption'], 'Nice video'); + }); + + test('toJson omits width/height when null', () { + const block = EmbedBlock( + service: 'youtube', + source: 'https://yt.be/x', + embed: 'https://yt.be/embed/x', + ); + final json = block.toJson(); + expect(json.containsKey('width'), false); + expect(json.containsKey('height'), false); + }); + }); + + // --------------------------------------------------------------------------- + // LinkToolBlock + // --------------------------------------------------------------------------- + group('LinkToolBlock', () { + test('type getter returns "linkTool"', () { + const block = LinkToolBlock(link: 'https://x.com'); + expect(block.type, 'linkTool'); + }); + + test('toJson contains link', () { + const block = LinkToolBlock( + link: 'https://x.com', + meta: LinkToolMeta(title: 'X'), + ); + final json = block.toJson(); + expect(json['link'], 'https://x.com'); + final meta = json['meta'] as Map; + expect(meta['title'], 'X'); + }); + + test('toJson omits meta when null', () { + const block = LinkToolBlock(link: 'https://x.com'); + final json = block.toJson(); + expect(json.containsKey('meta'), false); + }); + + test('LinkToolMeta toJson with imageUrl', () { + const meta = LinkToolMeta( + title: 'Title', + description: 'Desc', + imageUrl: 'https://x.com/img.png', + ); + final json = meta.toJson(); + expect(json['title'], 'Title'); + expect(json['description'], 'Desc'); + final image = json['image'] as Map; + expect(image['url'], 'https://x.com/img.png'); + }); + }); + + // --------------------------------------------------------------------------- + // AttachesBlock + // --------------------------------------------------------------------------- + group('AttachesBlock', () { + test('type getter returns "attaches"', () { + const block = AttachesBlock( + url: 'https://x.com/f.pdf', + extension: 'pdf', + size: 1024, + ); + expect(block.type, 'attaches'); + }); + + test('toJson contains file.url and extension and size', () { + const block = AttachesBlock( + url: 'https://x.com/f.pdf', + extension: 'pdf', + size: 1024, + ); + final json = block.toJson(); + final file = json['file'] as Map; + expect(file['url'], 'https://x.com/f.pdf'); + expect(file['extension'], 'pdf'); + expect(file['size'], 1024); + }); + + test('toJson title defaults to empty string when null', () { + const block = AttachesBlock(url: 'https://x.com/f.pdf'); + final json = block.toJson(); + expect(json['title'], ''); + }); + }); + + // --------------------------------------------------------------------------- + // RawBlock + // --------------------------------------------------------------------------- + group('RawBlock', () { + test('type getter returns "raw"', () { + const block = RawBlock(html: '

raw

'); + expect(block.type, 'raw'); + }); + + test('toJson contains html field', () { + const block = RawBlock(html: '

raw

'); + final json = block.toJson(); + expect(json['html'], '

raw

'); + }); + }); + + // --------------------------------------------------------------------------- + // BlockDocument round-trip + // --------------------------------------------------------------------------- + group('BlockDocument', () { + test('toJson round-trip', () { + const doc = BlockDocument( + time: 1000, + version: '2.19.0', + blocks: [ + HeaderBlock(text: 'Title', level: 1), + ParagraphBlock(html: 'Body'), + ], + ); + final json = doc.toJson(); + expect(json['time'], 1000); + expect(json['version'], '2.19.0'); + final blocks = json['blocks'] as List; + expect(blocks.length, 2); + final first = blocks[0] as Map; + expect(first['type'], 'header'); + expect((first['data'] as Map)['text'], 'Title'); + }); + + test('toJson can be serialized to JSON string and back', () { + const doc = BlockDocument( + time: 500, + version: '2.19.0', + blocks: [DelimiterBlock()], + ); + final jsonStr = jsonEncode(doc.toJson()); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded['time'], 500); + final blocks = decoded['blocks'] as List; + expect(blocks.length, 1); + expect((blocks[0] as Map)['type'], 'delimiter'); + }); + }); +} diff --git a/test/unit/editor_controller_test.dart b/test/unit/editor_controller_test.dart index df01afa..ddea793 100644 --- a/test/unit/editor_controller_test.dart +++ b/test/unit/editor_controller_test.dart @@ -192,4 +192,72 @@ void main() { expect((second['data'] as Map)['text'], '

Body

'); }); }); + + // --------------------------------------------------------------------------- + // fromJson + // --------------------------------------------------------------------------- + group('fromJson', () { + test('pre-populates blocks from valid JSON', () { + const json = + '{"blocks":[{"type":"header","data":{"text":"Hello","level":1}}]}'; + final ctrl = EditorController.fromJson(json); + expect(ctrl.blockCount, 1); + expect(ctrl.blocks.first, isA()); + expect((ctrl.blocks.first as HeaderBlock).text, 'Hello'); + }); + + test('empty controller on invalid JSON', () { + final ctrl = EditorController.fromJson('not valid json'); + expect(ctrl.blockCount, 0); + }); + + test('empty controller on empty string', () { + final ctrl = EditorController.fromJson(''); + expect(ctrl.blockCount, 0); + }); + + test('unknown block types are silently dropped', () { + const json = + '{"blocks":[{"type":"unknown_xyz","data":{}},{"type":"header","data":{"text":"Known","level":1}}]}'; + final ctrl = EditorController.fromJson(json); + expect(ctrl.blockCount, 1); + expect(ctrl.blocks.first, isA()); + }); + + test('multiple blocks parsed correctly', () { + const json = ''' + { + "blocks": [ + {"type": "header", "data": {"text": "Title", "level": 2}}, + {"type": "paragraph", "data": {"text": "Bold"}}, + {"type": "delimiter", "data": {}} + ] + } + '''; + final ctrl = EditorController.fromJson(json); + expect(ctrl.blockCount, 3); + expect(ctrl.blocks[0], isA()); + expect(ctrl.blocks[1], isA()); + expect(ctrl.blocks[2], isA()); + }); + + test('getContent round-trip from fromJson', () { + const json = + '{"blocks":[{"type":"header","data":{"text":"Round Trip","level":3}}]}'; + final ctrl = EditorController.fromJson(json); + final output = ctrl.getContent(); + final decoded = jsonDecode(output) as Map; + final blocks = decoded['blocks'] as List; + expect(blocks.length, 1); + expect((blocks[0] as Map)['type'], 'header'); + }); + + test('respects custom typeRegistry parameter', () { + // Using default registry — header should be parsed + const json = + '{"blocks":[{"type":"header","data":{"text":"Hi","level":1}}]}'; + final ctrl = EditorController.fromJson(json); + expect(ctrl.blockCount, 1); + }); + }); } diff --git a/test/unit/html_style_builder_test.dart b/test/unit/html_style_builder_test.dart new file mode 100644 index 0000000..5e71b65 --- /dev/null +++ b/test/unit/html_style_builder_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/src/presentation/utils/html_style_builder.dart'; +import 'package:editorjs_flutter/src/domain/entities/style_config.dart'; + +void main() { + group('HtmlStyleBuilder', () { + test('build(null) returns empty map', () { + final result = HtmlStyleBuilder.build(null); + expect(result, isEmpty); + }); + + test('build with defaultFont returns map with body key', () { + const config = StyleConfig(cssTags: [], defaultFont: 'Roboto'); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('body'), true); + expect(result['body']!.fontFamily, 'Roboto'); + }); + + test('build with cssTags returns map with tag key', () { + const config = StyleConfig( + cssTags: [ + CssTagConfig(tag: 'mark', backgroundColor: '#ffff00'), + ], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('mark'), true); + }); + + test('build with defaultFont and cssTags has both body and code keys', () { + const config = StyleConfig( + defaultFont: 'Roboto', + cssTags: [CssTagConfig(tag: 'code')], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('body'), true); + expect(result.containsKey('code'), true); + }); + + test('build with StyleConfig with no defaultFont has no body key', () { + const config = StyleConfig(cssTags: []); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('body'), false); + }); + + test('build with color in tag config', () { + const config = StyleConfig( + cssTags: [CssTagConfig(tag: 'b', color: '#ff0000')], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('b'), true); + }); + + test('build with padding in tag config', () { + const config = StyleConfig( + cssTags: [CssTagConfig(tag: 'p', padding: 8.0)], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('p'), true); + }); + + test('build handles 8-digit ARGB hex color', () { + const config = StyleConfig( + cssTags: [CssTagConfig(tag: 'em', backgroundColor: 'FF123456')], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('em'), true); + }); + + test('build handles invalid hex color gracefully', () { + const config = StyleConfig( + cssTags: [CssTagConfig(tag: 'em', backgroundColor: 'ZZZZZZ')], + ); + final result = HtmlStyleBuilder.build(config); + // Should not throw, and the tag should still be present + expect(result.containsKey('em'), true); + }); + + test('build handles too-short hex color gracefully', () { + const config = StyleConfig( + cssTags: [CssTagConfig(tag: 'em', backgroundColor: 'FFF')], + ); + final result = HtmlStyleBuilder.build(config); + expect(result.containsKey('em'), true); + }); + }); +} diff --git a/test/widget/block_editors_test.dart b/test/widget/block_editors_test.dart new file mode 100644 index 0000000..4ee1c4c --- /dev/null +++ b/test/widget/block_editors_test.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/paragraph/paragraph_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/list/list_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/checklist/checklist_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/table/table_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/image/image_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/header/header_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/quote/quote_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/warning/warning_editor.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/code/code_editor.dart'; + +Widget _wrap(Widget child) => MaterialApp( + home: Scaffold( + body: SizedBox(width: 400, child: child), + ), + ); + +void main() { + // --------------------------------------------------------------------------- + // ParagraphEditor + // --------------------------------------------------------------------------- + group('ParagraphEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = ParagraphBlock(html: 'Hello'); + await tester.pumpWidget(_wrap( + ParagraphEditor( + block: block, + onChanged: (_) {}, + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(ParagraphEditor), findsOneWidget); + }); + + testWidgets('shows existing text in text field', (tester) async { + const block = ParagraphBlock(html: 'My paragraph text'); + await tester.pumpWidget(_wrap( + ParagraphEditor( + block: block, + onChanged: (_) {}, + ), + )); + await tester.pumpAndSettle(); + expect(find.text('My paragraph text'), findsOneWidget); + }); + + testWidgets('typing in field calls onChanged', (tester) async { + ParagraphBlock? updatedBlock; + const block = ParagraphBlock(html: ''); + await tester.pumpWidget(_wrap( + ParagraphEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'New text'); + await tester.pumpAndSettle(); + expect(updatedBlock?.html, 'New text'); + }); + + testWidgets('format bar appears when field is focused', (tester) async { + const block = ParagraphBlock(html: 'Some text'); + await tester.pumpWidget(_wrap( + ParagraphEditor( + block: block, + onChanged: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + // Tap the text field to focus it + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Format bar buttons should appear + expect(find.text('B'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // ListEditor + // --------------------------------------------------------------------------- + group('ListEditor', () { + testWidgets('renders unordered list without throwing', (tester) async { + const block = ListBlock( + style: ListStyle.unordered, + items: [ + ListItem(content: 'Item 1'), + ListItem(content: 'Item 2'), + ], + ); + await tester.pumpWidget(_wrap( + ListEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('Item 1'), findsOneWidget); + }); + + testWidgets('renders ordered list without throwing', (tester) async { + const block = ListBlock( + style: ListStyle.ordered, + items: [ + ListItem(content: 'First'), + ], + ); + await tester.pumpWidget(_wrap( + ListEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('First'), findsOneWidget); + }); + + testWidgets('Add item button adds a new item', (tester) async { + ListBlock? updatedBlock; + const block = ListBlock( + style: ListStyle.unordered, + items: [ListItem(content: 'Item 1')], + ); + await tester.pumpWidget(_wrap( + ListEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add item')); + await tester.pumpAndSettle(); + expect(updatedBlock?.items.length, 2); + }); + }); + + // --------------------------------------------------------------------------- + // ChecklistEditor + // --------------------------------------------------------------------------- + group('ChecklistEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = ChecklistBlock( + items: [ + ChecklistItem(text: 'Task one', checked: false), + ChecklistItem(text: 'Task two', checked: true), + ], + ); + await tester.pumpWidget(_wrap( + ChecklistEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('Task one'), findsOneWidget); + }); + + testWidgets('toggling checkbox calls onChanged', (tester) async { + ChecklistBlock? updatedBlock; + const block = ChecklistBlock( + items: [ChecklistItem(text: 'Do something', checked: false)], + ); + await tester.pumpWidget(_wrap( + ChecklistEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(updatedBlock?.items.first.checked, true); + }); + + testWidgets('Add item button works', (tester) async { + ChecklistBlock? updatedBlock; + const block = ChecklistBlock( + items: [ChecklistItem(text: 'First', checked: false)], + ); + await tester.pumpWidget(_wrap( + ChecklistEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add item')); + await tester.pumpAndSettle(); + expect(updatedBlock?.items.length, 2); + }); + }); + + // --------------------------------------------------------------------------- + // TableEditor + // --------------------------------------------------------------------------- + group('TableEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = TableBlock( + content: [ + ['A', 'B'], + ['C', 'D'], + ], + ); + await tester.pumpWidget(_wrap( + TableEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.byType(TableEditor), findsOneWidget); + }); + + testWidgets('Add Row button adds a row', (tester) async { + TableBlock? updatedBlock; + const block = TableBlock( + content: [ + ['A', 'B'], + ], + ); + await tester.pumpWidget(_wrap( + TableEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Row')); + await tester.pumpAndSettle(); + expect(updatedBlock?.content.length, 2); + }); + + testWidgets('Add Column button adds a column', (tester) async { + TableBlock? updatedBlock; + const block = TableBlock( + content: [ + ['A'], + ], + ); + await tester.pumpWidget(_wrap( + TableEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Column')); + await tester.pumpAndSettle(); + expect(updatedBlock?.content.first.length, 2); + }); + + testWidgets('withHeadings checkbox toggles', (tester) async { + TableBlock? updatedBlock; + const block = TableBlock( + content: [ + ['A', 'B'], + ['C', 'D'], + ], + withHeadings: false, + ); + await tester.pumpWidget(_wrap( + TableEditor( + block: block, + onChanged: (b) => updatedBlock = b, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(updatedBlock?.withHeadings, true); + }); + + testWidgets('empty table content initializes correctly', (tester) async { + const block = TableBlock(content: []); + await tester.pumpWidget(_wrap( + TableEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.byType(TableEditor), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // ImageEditor + // --------------------------------------------------------------------------- + group('ImageEditor', () { + testWidgets('renders without URL without throwing', (tester) async { + const block = ImageBlock(url: ''); + await tester.pumpWidget(_wrap( + ImageEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.byType(ImageEditor), findsOneWidget); + }); + + testWidgets('renders with URL without throwing', (tester) async { + const block = ImageBlock(url: 'https://example.com/img.png'); + await tester.pumpWidget(_wrap( + ImageEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.byType(ImageEditor), findsOneWidget); + }); + + testWidgets('camera and gallery buttons are present', (tester) async { + const block = ImageBlock(url: ''); + await tester.pumpWidget(_wrap( + ImageEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Gallery'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // HeaderEditor + // --------------------------------------------------------------------------- + group('HeaderEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = HeaderBlock(text: 'Title', level: 1); + await tester.pumpWidget(_wrap( + HeaderEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.byType(HeaderEditor), findsOneWidget); + }); + + testWidgets('shows existing text', (tester) async { + const block = HeaderBlock(text: 'My Heading', level: 2); + await tester.pumpWidget(_wrap( + HeaderEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('My Heading'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // QuoteEditor + // --------------------------------------------------------------------------- + group('QuoteEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = QuoteBlock(text: 'A quote', alignment: QuoteAlignment.left); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + child: QuoteEditor(block: block, onChanged: (_) {}), + ), + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(QuoteEditor), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // WarningEditor + // --------------------------------------------------------------------------- + group('WarningEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = WarningBlock(title: 'Note', message: 'Watch out'); + await tester.pumpWidget(_wrap( + WarningEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('Note'), findsOneWidget); + expect(find.text('Watch out'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // CodeEditor + // --------------------------------------------------------------------------- + group('CodeEditor', () { + testWidgets('renders without throwing', (tester) async { + const block = CodeBlock(code: 'print("hello")'); + await tester.pumpWidget(_wrap( + CodeEditor(block: block, onChanged: (_) {}), + )); + await tester.pumpAndSettle(); + expect(find.text('print("hello")'), findsOneWidget); + }); + }); +} diff --git a/test/widget/editorjs_editor_test.dart b/test/widget/editorjs_editor_test.dart new file mode 100644 index 0000000..3b642a6 --- /dev/null +++ b/test/widget/editorjs_editor_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; + +void main() { + testWidgets('EditorJSEditor renders empty editor without throwing', + (tester) async { + final controller = EditorController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSEditor), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders blocks from controller', (tester) async { + final controller = EditorController( + initialBlocks: const [ + HeaderBlock(text: 'My Title', level: 1), + ParagraphBlock(html: 'Some text'), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('My Title'), findsOneWidget); + }); + + testWidgets('EditorController.fromJson pre-populates blocks', (tester) async { + const json = + '{"blocks":[{"type":"header","data":{"text":"From JSON","level":2}}]}'; + final controller = EditorController.fromJson(json); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('From JSON'), findsOneWidget); + }); + + testWidgets('move up / move down controls are present', (tester) async { + final controller = EditorController( + initialBlocks: const [ + HeaderBlock(text: 'Block 1', level: 1), + HeaderBlock(text: 'Block 2', level: 1), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + // Move-down icon should appear (first block can go down) + expect(find.byIcon(Icons.arrow_downward), findsWidgets); + }); + + testWidgets('EditorJSEditor renders checklist block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + ChecklistBlock( + items: [ + ChecklistItem(text: 'Check me', checked: false), + ], + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Check me'), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders code block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + CodeBlock(code: 'void main() {}'), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('void main() {}'), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders warning block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + WarningBlock(title: 'Alert', message: 'Watch out'), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Alert'), findsOneWidget); + expect(find.text('Watch out'), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders quote block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + QuoteBlock(text: 'A famous quote', alignment: QuoteAlignment.left), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSEditor), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders table block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + TableBlock( + content: [ + ['Col1', 'Col2'], + ['Val1', 'Val2'], + ], + withHeadings: true, + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Col1'), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders list block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + ListBlock( + style: ListStyle.unordered, + items: [ + ListItem(content: 'List item'), + ], + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSEditor), findsOneWidget); + }); + + testWidgets('EditorJSEditor renders delimiter block', (tester) async { + final controller = EditorController( + initialBlocks: const [ + DelimiterBlock(), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSEditor(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Divider), findsOneWidget); + }); +} diff --git a/test/widget/editorjs_view_test.dart b/test/widget/editorjs_view_test.dart new file mode 100644 index 0000000..ff48cb5 --- /dev/null +++ b/test/widget/editorjs_view_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; + +void main() { + const sampleJson = ''' + { + "time": 1000, + "version": "2.19.0", + "blocks": [ + {"id": "1", "type": "header", "data": {"text": "Hello World", "level": 1}}, + {"id": "2", "type": "paragraph", "data": {"text": "A paragraph."}}, + {"id": "3", "type": "delimiter", "data": {}}, + {"id": "4", "type": "code", "data": {"code": "print('hi')"}}, + {"id": "5", "type": "warning", "data": {"title": "Note", "message": "Be careful"}} + ] + } + '''; + + testWidgets('EditorJSView renders known blocks without throwing', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSView(jsonData: sampleJson), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Hello World'), findsOneWidget); + expect(find.text("print('hi')"), findsOneWidget); + }); + + testWidgets('EditorJSView handles invalid JSON without throwing', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSView(jsonData: 'not json'), + ), + ), + ); + await tester.pumpAndSettle(); + // Should render empty content, not crash + expect(find.byType(EditorJSView), findsOneWidget); + }); + + testWidgets('EditorJSView drops unknown block types silently', (tester) async { + const json = '{"blocks":[{"type":"unknown_xyz","data":{}}]}'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EditorJSView(jsonData: json), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSView), findsOneWidget); + }); + + testWidgets('EditorJSView renders warning block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "warning", "data": {"title": "Note", "message": "Be careful"}} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Note'), findsOneWidget); + expect(find.text('Be careful'), findsOneWidget); + }); + + testWidgets('EditorJSView renders checklist block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "checklist", "data": { + "items": [ + {"text": "Task one", "checked": false}, + {"text": "Task two", "checked": true} + ] + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Task one'), findsOneWidget); + }); + + testWidgets('EditorJSView renders table block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "table", "data": { + "withHeadings": true, + "content": [["Name", "Value"], ["Foo", "Bar"]] + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Name'), findsOneWidget); + expect(find.text('Foo'), findsOneWidget); + }); + + testWidgets('EditorJSView renders quote block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "quote", "data": { + "text": "A great quote", + "caption": "Famous Person", + "alignment": "left" + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Famous Person'), findsOneWidget); + }); + + testWidgets('EditorJSView renders embed block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "embed", "data": { + "service": "youtube", + "source": "https://youtube.com/watch?v=test", + "embed": "https://www.youtube.com/embed/test" + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Youtube'), findsOneWidget); + }); + + testWidgets('EditorJSView renders attaches block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "attaches", "data": { + "file": {"url": "https://example.com/doc.pdf", "name": "doc.pdf"}, + "title": "My Document" + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('My Document'), findsOneWidget); + }); + + testWidgets('EditorJSView renders list block', (tester) async { + const json = ''' + { + "blocks": [ + {"type": "list", "data": { + "style": "unordered", + "items": [ + {"content": "Item one", "items": []}, + {"content": "Item two", "items": []} + ] + }} + ] + } + '''; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: EditorJSView(jsonData: json)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSView), findsOneWidget); + }); +} diff --git a/test/widget/renderers_test.dart b/test/widget/renderers_test.dart index 4f83157..5ad7445 100644 --- a/test/widget/renderers_test.dart +++ b/test/widget/renderers_test.dart @@ -8,6 +8,13 @@ import 'package:editorjs_flutter/src/presentation/blocks/warning/warning_rendere import 'package:editorjs_flutter/src/presentation/blocks/checklist/checklist_renderer.dart'; import 'package:editorjs_flutter/src/presentation/blocks/embed/embed_renderer.dart'; import 'package:editorjs_flutter/src/presentation/blocks/attaches/attaches_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/paragraph/paragraph_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/quote/quote_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/list/list_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/table/table_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/image/image_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/link_tool/link_tool_renderer.dart'; +import 'package:editorjs_flutter/src/presentation/blocks/raw/raw_renderer.dart'; Widget _wrap(Widget child) => MaterialApp( home: Scaffold( @@ -143,4 +150,236 @@ void main() { expect(find.text('report.pdf'), findsOneWidget); }); }); + + // --------------------------------------------------------------------------- + // ParagraphRenderer + // --------------------------------------------------------------------------- + group('ParagraphRenderer', () { + testWidgets('renders HTML content without throwing', (tester) async { + const block = ParagraphBlock(html: 'Bold text'); + await tester + .pumpWidget(_wrap(const ParagraphRenderer(block: block))); + await tester.pumpAndSettle(); + // Rendered without throw — flutter_html renders inside RichText + expect(find.byType(ParagraphRenderer), findsOneWidget); + }); + + testWidgets('renders plain text content without throwing', (tester) async { + const block = ParagraphBlock(html: 'Plain text'); + await tester + .pumpWidget(_wrap(const ParagraphRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(ParagraphRenderer), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // QuoteRenderer + // --------------------------------------------------------------------------- + group('QuoteRenderer', () { + testWidgets('shows quote text and pumps without throw', (tester) async { + const block = QuoteBlock( + text: 'A wise quote.', + alignment: QuoteAlignment.left, + ); + await tester + .pumpWidget(_wrap(const QuoteRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(QuoteRenderer), findsOneWidget); + }); + + testWidgets('shows caption when provided', (tester) async { + const block = QuoteBlock( + text: 'A wise quote.', + caption: 'Author Name', + alignment: QuoteAlignment.center, + ); + await tester + .pumpWidget(_wrap(const QuoteRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.textContaining('Author Name'), findsOneWidget); + }); + + testWidgets('right alignment pumps without throw', (tester) async { + const block = QuoteBlock( + text: 'Aligned right.', + alignment: QuoteAlignment.right, + ); + await tester + .pumpWidget(_wrap(const QuoteRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(QuoteRenderer), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // ListRenderer — unordered + // --------------------------------------------------------------------------- + group('ListRenderer', () { + testWidgets('unordered list shows item text', (tester) async { + const block = ListBlock( + style: ListStyle.unordered, + items: [ + ListItem(content: 'Apple'), + ListItem(content: 'Banana'), + ], + ); + await tester + .pumpWidget(_wrap(const ListRenderer(block: block))); + await tester.pumpAndSettle(); + // flutter_html renders content so we just check the widget tree + expect(find.byType(ListRenderer), findsOneWidget); + }); + + testWidgets('ordered list pumps without throw', (tester) async { + const block = ListBlock( + style: ListStyle.ordered, + items: [ + ListItem(content: 'First'), + ListItem(content: 'Second'), + ], + ); + await tester + .pumpWidget(_wrap(const ListRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(ListRenderer), findsOneWidget); + }); + + testWidgets('nested list renders without throw', (tester) async { + const block = ListBlock( + style: ListStyle.unordered, + items: [ + ListItem( + content: 'Parent', + items: [ + ListItem(content: 'Child'), + ], + ), + ], + ); + await tester + .pumpWidget(_wrap(const ListRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(ListRenderer), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // TableRenderer + // --------------------------------------------------------------------------- + group('TableRenderer', () { + testWidgets('shows cell content', (tester) async { + const block = TableBlock(content: [ + ['Name', 'Age'], + ['Alice', '30'], + ]); + await tester + .pumpWidget(_wrap(const TableRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.text('Name'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + }); + + testWidgets('with headings first row is rendered', (tester) async { + const block = TableBlock( + content: [ + ['ID', 'Value'], + ['1', 'foo'], + ], + withHeadings: true, + ); + await tester + .pumpWidget(_wrap(const TableRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.text('ID'), findsOneWidget); + }); + + testWidgets('empty content renders without throw', (tester) async { + const block = TableBlock(content: []); + await tester + .pumpWidget(_wrap(const TableRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(TableRenderer), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // ImageRenderer + // --------------------------------------------------------------------------- + group('ImageRenderer', () { + testWidgets('pumps without throw (image load will fail)', (tester) async { + const block = ImageBlock(url: 'https://example.com/nonexistent.png'); + await tester + .pumpWidget(_wrap(const ImageRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(ImageRenderer), findsOneWidget); + }); + + testWidgets('shows caption when provided', (tester) async { + const block = ImageBlock( + url: 'https://example.com/img.png', + caption: 'A photo', + ); + await tester + .pumpWidget(_wrap(const ImageRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.text('A photo'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // LinkToolRenderer + // --------------------------------------------------------------------------- + group('LinkToolRenderer', () { + testWidgets('pumps without throw', (tester) async { + const block = LinkToolBlock( + link: 'https://example.com', + meta: LinkToolMeta(title: 'Example', description: 'A test site'), + ); + await tester + .pumpWidget(_wrap(const LinkToolRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(LinkToolRenderer), findsOneWidget); + }); + + testWidgets('shows link title', (tester) async { + const block = LinkToolBlock( + link: 'https://example.com', + meta: LinkToolMeta(title: 'My Link Title'), + ); + await tester + .pumpWidget(_wrap(const LinkToolRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.text('My Link Title'), findsOneWidget); + }); + + testWidgets('pumps without throw when meta is null', (tester) async { + const block = LinkToolBlock(link: 'https://example.com'); + await tester + .pumpWidget(_wrap(const LinkToolRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(LinkToolRenderer), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // RawRenderer + // --------------------------------------------------------------------------- + group('RawRenderer', () { + testWidgets('renders sanitized HTML without throwing', (tester) async { + const block = RawBlock(html: '

Hello world

'); + await tester + .pumpWidget(_wrap(const RawRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(RawRenderer), findsOneWidget); + }); + + testWidgets('renders with script tag stripped', (tester) async { + const block = RawBlock(html: '

Safe

'); + await tester + .pumpWidget(_wrap(const RawRenderer(block: block))); + await tester.pumpAndSettle(); + expect(find.byType(RawRenderer), findsOneWidget); + }); + }); } diff --git a/test/widget/toolbar_test.dart b/test/widget/toolbar_test.dart new file mode 100644 index 0000000..7ca3409 --- /dev/null +++ b/test/widget/toolbar_test.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:editorjs_flutter/editorjs_flutter.dart'; +import 'package:editorjs_flutter/src/presentation/widgets/editorjs_toolbar.dart'; + +Widget _wrapToolbar(EditorController controller) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + EditorJSToolbar( + controller: controller, + rendererRegistry: BlockRendererRegistry(), + ), + ], + ), + ), + ); + +void main() { + group('EditorJSToolbar', () { + testWidgets('renders without throwing', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + expect(find.byType(EditorJSToolbar), findsOneWidget); + }); + + testWidgets('tapping Paragraph button adds a paragraph block', + (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('T')); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping H1 button adds a header block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('H1')); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + expect((controller.blocks.first as HeaderBlock).level, 1); + }); + + testWidgets('tapping Delimiter button adds delimiter block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.horizontal_rule)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping Code button adds code block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.code)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping Quote button adds quote block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.format_quote)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping Warning button adds warning block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.warning_amber_outlined)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping unordered list button adds list block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.format_list_bulleted)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + expect((controller.blocks.first as ListBlock).style, ListStyle.unordered); + }); + + testWidgets('tapping ordered list button adds ordered list block', + (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.format_list_numbered)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect((controller.blocks.first as ListBlock).style, ListStyle.ordered); + }); + + testWidgets('tapping checklist button adds checklist block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.checklist)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping table button adds table block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.table_chart_outlined)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping image button adds image block', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.image_outlined)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + + testWidgets('tapping delete last block button removes last block', + (tester) async { + final controller = EditorController( + initialBlocks: const [ + HeaderBlock(text: 'Block to delete', level: 1), + ], + ); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + // Scroll to reveal delete button (it may be off-screen in the horizontal scroll) + await tester.scrollUntilVisible( + find.byIcon(Icons.delete_outline), + 50.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 0); + }); + + testWidgets('undo button does nothing when canUndo is false', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + // Scroll to reveal undo button + await tester.scrollUntilVisible( + find.byIcon(Icons.undo), + 50.0, + scrollable: find.byType(Scrollable).first, + ); + // Tap undo when nothing to undo — disabled so no-op + await tester.tap(find.byIcon(Icons.undo), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(controller.blockCount, 0); + }); + + testWidgets('undo button works after adding a block', (tester) async { + final controller = EditorController(); + controller.addBlock(const HeaderBlock(text: 'A', level: 1)); + expect(controller.blockCount, 1); + + await tester.pumpWidget(_wrapToolbar(controller)); + await tester.pumpAndSettle(); + + // Scroll to reveal undo button + await tester.scrollUntilVisible( + find.byIcon(Icons.undo), + 50.0, + scrollable: find.byType(Scrollable).first, + ); + + // Tap undo + await tester.tap(find.byIcon(Icons.undo)); + await tester.pumpAndSettle(); + expect(controller.blockCount, 0); + }); + + testWidgets('hyperlink dialog opens and can be cancelled', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EditorJSToolbar( + controller: controller, + rendererRegistry: BlockRendererRegistry(), + ), + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.link)); + await tester.pumpAndSettle(); + + // Dialog should be showing + expect(find.text('Add hyperlink'), findsOneWidget); + + // Cancel the dialog + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(controller.blockCount, 0); + }); + + testWidgets('hyperlink dialog adds paragraph block with URL', (tester) async { + final controller = EditorController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EditorJSToolbar( + controller: controller, + rendererRegistry: BlockRendererRegistry(), + ), + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.link)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextField).last, 'https://example.com'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(controller.blockCount, 1); + expect(controller.blocks.first, isA()); + }); + }); +} From f36f615b36592e3fad69651a18cac903d9be9b65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:15:48 +0000 Subject: [PATCH 2/3] Initial plan From bd2aed1bd15c06db7f653492f81e9f6c3d22c8f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:19:03 +0000 Subject: [PATCH 3/3] Address PR review: custom typeRegistry test, CI version pin, example pubspec flutter section Co-authored-by: RZEROSTERN <3065243+RZEROSTERN@users.noreply.github.com> Agent-Logs-Url: https://github.com/RZEROSTERN/editorjs-flutter/sessions/913476ea-4c30-4220-b059-270bfa4fcc70 --- .github/workflows/ci.yml | 1 + example/pubspec.yaml | 3 +++ test/unit/editor_controller_test.dart | 32 +++++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa66287..e0ed498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: - uses: subosito/flutter-action@v2 with: + flutter-version: '3.41.5' channel: stable - name: Install dependencies diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fc650fb..0200ead 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,3 +11,6 @@ dependencies: sdk: flutter editorjs_flutter: path: ../ + +flutter: + uses-material-design: true diff --git a/test/unit/editor_controller_test.dart b/test/unit/editor_controller_test.dart index ddea793..8a065d8 100644 --- a/test/unit/editor_controller_test.dart +++ b/test/unit/editor_controller_test.dart @@ -253,11 +253,39 @@ void main() { }); test('respects custom typeRegistry parameter', () { - // Using default registry — header should be parsed const json = '{"blocks":[{"type":"header","data":{"text":"Hi","level":1}}]}'; - final ctrl = EditorController.fromJson(json); + + // Custom registry that alters how header blocks are created + final customRegistry = BlockTypeRegistry() + ..register(_PrefixedHeaderMapper('Custom: ')); + + final ctrl = EditorController.fromJson( + json, + typeRegistry: customRegistry, + ); + expect(ctrl.blockCount, 1); + expect(ctrl.blocks.first, isA()); + final headerBlock = ctrl.blocks.first as HeaderBlock; + expect(headerBlock.text, 'Custom: Hi'); + expect(headerBlock.level, 1); }); }); } + +/// A [BlockMapper] that prefixes header text — used to verify custom +/// registries are respected by [EditorController.fromJson]. +class _PrefixedHeaderMapper implements BlockMapper { + final String prefix; + const _PrefixedHeaderMapper(this.prefix); + + @override + String get supportedType => 'header'; + + @override + HeaderBlock fromJson(Map data) => HeaderBlock( + text: '$prefix${data['text'] ?? ''}', + level: data['level'] is int ? data['level'] as int : 1, + ); +}