├── .editorconfig ├── .eslintrc.yml ├── .gitattributes ├── .github └── workflows │ ├── auto-format-code.yml │ └── test-code.yml ├── .gitignore ├── CHANGELOG.md ├── EXTENDING.md ├── README.md ├── UPGRADING.md ├── composer.json ├── dist ├── css │ └── field.css ├── js │ ├── field.js │ ├── field.js.LICENSE.txt │ ├── index.js │ └── index.js.LICENSE.txt └── mix-manifest.json ├── nova.mix.js ├── package-lock.json ├── package.json ├── phpunit.xml ├── pint.json ├── resources ├── css │ └── field.css ├── js │ ├── blocks │ │ ├── checklist.js │ │ ├── code.js │ │ ├── delimiter.js │ │ ├── embed.js │ │ ├── heading.js │ │ ├── image.js │ │ ├── inline-code.js │ │ ├── link.js │ │ ├── list.js │ │ ├── marker.js │ │ ├── paragraph.js │ │ ├── raw.js │ │ └── table.js │ ├── components │ │ ├── DetailField.vue │ │ ├── FormField.vue │ │ └── IndexField.vue │ ├── field.js │ ├── index.js │ └── nova-editor.js └── views │ ├── checklist.blade.php │ ├── code.blade.php │ ├── content.blade.php │ ├── delimiter.blade.php │ ├── embed.blade.php │ ├── heading.blade.php │ ├── image.blade.php │ ├── link.blade.php │ ├── list.blade.php │ ├── paragraph.blade.php │ ├── raw.blade.php │ └── table.blade.php ├── routes └── api.php ├── src ├── Events │ ├── EditorJsImageUploaded.php │ └── EditorJsThumbnailCreated.php ├── FieldServiceProvider.php ├── Http │ └── Controllers │ │ ├── EditorJsImageUploadController.php │ │ └── EditorJsLinkController.php ├── NovaEditorJs.php ├── NovaEditorJsCast.php ├── NovaEditorJsConverter.php ├── NovaEditorJsData.php ├── NovaEditorJsField.php └── config │ └── nova-editor-js.php ├── tests ├── Feature │ ├── Http │ │ └── Controllers │ │ │ ├── EditorJsImageUploadControllerTest.php │ │ │ └── EditorJsLinkControllerTest.php │ └── Views │ │ ├── LinkViewTest.php │ │ └── ViewTestHelpers.php ├── Fixtures │ ├── Models │ │ └── Dummy.php │ ├── TestServiceProvider.php │ ├── database │ │ └── migrations │ │ │ └── 2022_07_17_153928_create_dummies_table.php │ ├── nova │ │ ├── composer.json │ │ └── src │ │ │ ├── Events │ │ │ └── ServingNova.php │ │ │ ├── StaticallyUselessClass.php │ │ │ ├── UselessServiceProvider.php │ │ │ └── aliases.php │ └── resources │ │ ├── html │ │ └── editorjs.html │ │ └── json │ │ └── editorjs.json ├── TestCase.php ├── Unit │ ├── JsonContentTest.php │ └── NovaEditorJsCastTest.php ├── helpers.php └── resources │ └── responses │ ├── image.gif │ ├── image.jpg │ ├── image.png │ ├── image.svg │ ├── image.txt │ ├── image.webp │ ├── simple.html │ └── with-image.html └── webpack.mix.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{yaml,yml,md}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - airbnb-base 3 | - plugin:vue/vue3-recommended 4 | 5 | globals: 6 | _: readonly 7 | Nova: readonly 8 | NovaEditorJS: writable 9 | 10 | settings: 11 | import/resolver: 12 | node: 13 | extensions: ['.js', '.vue'] 14 | 15 | rules: 16 | global-require: off 17 | indent: 18 | - error 19 | - 4 20 | no-param-reassign: 21 | - off 22 | - props: false 23 | vue/require-prop-types: off 24 | vue/html-indent: 25 | - error 26 | - 4 27 | import/extensions: off 28 | import/no-unresolved: 29 | - error 30 | - ignore: 31 | - laravel-nova 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /dist/js/* -text merge=binary 2 | /dist/css/* -text merge=binary 3 | -------------------------------------------------------------------------------- /.github/workflows/auto-format-code.yml: -------------------------------------------------------------------------------- 1 | name: Verify code formatting 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | - master 8 | push: 9 | branches: 10 | - develop 11 | 12 | permissions: 13 | # Give the default GITHUB_TOKEN write permission to commit and push the 14 | # added or changed files to the repository. 15 | contents: write 16 | 17 | jobs: 18 | test-formatting: 19 | name: Auto-format code using php-cs-fixer 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Setup NodeJS 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: lts/* 32 | cache: 'npm' 33 | 34 | - name: Install NodeJS dependencies 35 | run: npm install --verbose --foreground-scripts 36 | 37 | - name: Setup PHP 38 | uses: shivammathur/setup-php@v2 39 | with: 40 | php-version: '8.3' 41 | extensions: exif,json,mbstring 42 | coverage: none 43 | 44 | - name: Configure local Laravel Nova dummy package 45 | run: | 46 | composer config repositories.0 path ./tests/Fixtures/nova 47 | git update-index --assume-unchanged composer.json 48 | 49 | - name: Install Composer dependencies 50 | uses: ramsey/composer-install@v3 51 | 52 | - name: Lint code 53 | run: | 54 | composer run format 55 | npm run format 56 | 57 | - name: Report changes 58 | id: report-changes 59 | run: | 60 | git diff --color=always 61 | echo " > git diff --shortstat" >> $GITHUB_STEP_SUMMARY 62 | echo " $( git diff --shortstat )" >> $GITHUB_STEP_SUMMARY 63 | echo "HAS_CHANGES=$( git diff --quiet && echo 'no' || echo 'yes' )" >> $GITHUB_OUTPUT 64 | 65 | - name: Fail on changes (pull request only) 66 | if: ${{ github.event_name == 'pull_request' && steps.report-changes.outputs.HAS_CHANGES == 'yes' }} 67 | run: | 68 | echo '::error title=Linting caused changes::Some files were modified by the linter, please run `composer format` to fix these' 69 | exit 1 70 | 71 | - name: Commit changes (push only) 72 | if: github.event_name == 'push' 73 | uses: stefanzweifel/git-auto-commit-action@v5 74 | with: 75 | commit_message: 'chore: fixed code formatting issues' 76 | 77 | -------------------------------------------------------------------------------- /.github/workflows/test-code.yml: -------------------------------------------------------------------------------- 1 | name: "Run unit tests" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test-laravel: 11 | name: Test Laravel ${{ matrix.laravel }} on PHP ${{ matrix.php }} 12 | runs-on: ubuntu-latest 13 | continue-on-error: ${{ matrix.experimental == true }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | php: 19 | - '8.2' 20 | - '8.3' 21 | - nightly 22 | 23 | laravel: 24 | - '10.0' 25 | - '11.0' 26 | - '12.0' 27 | 28 | include: 29 | - laravel: '10.0' 30 | testbench: '8.0' 31 | - laravel: '11.0' 32 | testbench: '9.0' 33 | - laravel: '12.0' 34 | testbench: '10.0' 35 | - php: 8.3 36 | laravel: '11.0' 37 | stable: true 38 | - php: nightly 39 | experimental: true 40 | 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: Setup PHP 46 | uses: shivammathur/setup-php@v2 47 | with: 48 | php-version: ${{ matrix.php }} 49 | extensions: exif,json,mbstring 50 | coverage: pcov 51 | 52 | - name: Configure to use Laravel ${{ matrix.laravel }} with Testbench ${{ matrix.testbench }} 53 | run: | 54 | composer require --no-update laravel/laravel:^${{ matrix.laravel }} 55 | composer require --no-update --dev orchestra/testbench:^${{ matrix.testbench }} 56 | 57 | - name: Configure local Laravel Nova dummy package 58 | run: composer config repositories.0 path ./tests/Fixtures/nova 59 | 60 | - name: Install Composer dependencies 61 | uses: ramsey/composer-install@v3 62 | with: 63 | composer-options: "--ignore-platform-req=php" 64 | 65 | - name: Run unit tests with coverage and printer 66 | id: phpunit 67 | run: | 68 | echo "phpunit_version=$( vendor/bin/phpunit --version | cut -d ' ' -f 2 )" >> $GITHUB_OUTPUT 69 | vendor/bin/phpunit \ 70 | --log-junit ./report-junit.xml \ 71 | --coverage-clover ./coverage-clover.xml 72 | 73 | - name: Report test results 74 | if: success() || failure() 75 | uses: mikepenz/action-junit-report@v4 76 | with: 77 | report_paths: ./report-junit.xml 78 | check_name: Laravel ${{ matrix.laravel }}, PHP ${{ matrix.php }} (PHPUnit ${{ steps.phpunit.outputs.phpunit_version }}) 79 | summary: | 80 | PHP version: `${{ matrix.php }}` 81 | Laravel version: `${{ matrix.laravel }}` 82 | Testbench version: `${{ matrix.testbench }}` 83 | PHPUnit version: `${{ steps.phpunit.outputs.phpunit_version }}` 84 | 85 | - name: Determine coverage 86 | uses: slavcodev/coverage-monitor-action@1.9.0 87 | if: github.event_name == 'pull_request' && matrix.stable == true 88 | continue-on-error: true 89 | with: 90 | github_token: ${{ secrets.GITHUB_TOKEN }} 91 | coverage_path: ./coverage-clover.xml 92 | threshold_alert: 60 93 | threshold_warning: 85 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | .phpunit.result.cache 5 | .DS_Store 6 | Thumbs.db 7 | .php-cs-fixer.cache 8 | /composer.lock 9 | /auth.json 10 | /report-junit.xml 11 | /.env 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [UNRELEASED] 9 | 10 | ## [4.0.0] 11 | 12 | ### Added 13 | 14 | - Added Laravel 11 support, via #104 by @woeler. 15 | - Added `EditorJsImageUploaded` event that triggers when an image is uploaded, via #98 by @woeler. 16 | - Added `EditorJsThumbnailCreated` event that triggers when a thumbnail is generated, via #98 by @woeler. 17 | 18 | ### Changed 19 | 20 | - Changed GitHub Actions to Node v20, via #106 by @roelofr. 21 | - Changed auto-formatter to use PHP 8.3, via #106 by @roelofr. 22 | 23 | ### Removed 24 | 25 | - Removed Laravel 9 support, via #104 by @woeler. 26 | 27 | ## [3.3.0] 28 | 29 | - Fixed invalid image reference `image.url` in `views/link.blade.php`, via #101 by @roelofr. 30 | 31 | ### Deprecated 32 | 33 | - Deprecated support for Laravel 8.x. It might still work, but we're not testing it anymore. 34 | 35 | ## [3.2.1] 36 | 37 | ### Changed 38 | 39 | - Improve GitHub Actions and test results, via #95 by @roelofr. 40 | 41 | ## [3.2.0] 42 | 43 | ### Added 44 | 45 | - Suport for Laravel 10, via #93 by @Woeler. 46 | 47 | ### Fixed 48 | 49 | - Fixed ignoring the resolveCallbacks on a field, via #83, by @Woeler. 50 | 51 | ## [3.1.0] 52 | 53 | ### Added 54 | 55 | - Guzzle is now a dependency of this project. 56 | - Added `php-cs-fixer` for code standards. 57 | - Added `php-parallel-lint` to ensure all files are actually valid PHP code. 58 | - Added `pretttier` for consistent Markdown files. 59 | - Added RTL support (`editorSettings.rtl`) 60 | 61 | ### Changed 62 | 63 | - Improved image upload handling, using Laravel-native libraries 64 | - Improved link metadata retrieval, using Laravel-native libraries 65 | 66 | ### Deprecated 67 | 68 | - Deprecated `editorSettings.initialBlock` in favor of `editorSettings.defaultBlock` to match EditorJS 69 | 70 | ### Fixed 71 | 72 | - Fixed HTML escaping on list and raw HTML fields. (#80 by @Harrk) 73 | 74 | ## [3.0.5] 75 | 76 | ### Fixed 77 | 78 | - When no changes are made to the editor, the value is left as-is, instead of double-encoding it (thanks @waelelsawy) 79 | - Templates for `list`', `paragraph` and `table` to use raw-html statements on cleaned fields. 80 | 81 | ## [3.0.4] 82 | 83 | ### Fixed 84 | 85 | - NovaEditorJsCast now properly handles JSON, not double-encoding stuff and decoding double-encoded properties. 86 | 87 | ## [3.0.3] 88 | 89 | ### Fixed 90 | 91 | - Constructor of `NovaEditorJsData` now accepts null values and non-iterables. 92 | - PHPDoc return type of `NovaEditorJsData::toHtml()`. 93 | 94 | ## [3.0.2] 95 | 96 | ### Added 97 | 98 | - Support for `spatie/image` version 2.x. 99 | 100 | ## [3.0.1] 101 | 102 | ### Fixed 103 | 104 | - `composer.json` didn't require PHP 8.1+, but the codebase did. 105 | 106 | ## [3.0.0] 107 | 108 | ### Added 109 | 110 | - Nova 4 support 111 | - `NovaEditorJsConverter` to split HTML conversion from the Nova Field 112 | - `NovaEditorJsData` model to store JSON data and allow easy HTML conversion 113 | - `NovaEditorJsCast` to easily convert between raw data and the `NovaEditorJsData` model 114 | - JS linter, EditorConfig and other tools for better development 115 | 116 | ### Changed 117 | 118 | - **PHP version requirements changed**, now requires PHP 8.1 or higher 119 | - `NovaEditorJs` facade for better separation of concerns 120 | - Improved README and separated extending docs to separate file 121 | - Updated Laravel Mix to new version 122 | - Updated Vue to version 3 123 | - The `NovaEditorJsField::displayUsing` now recieves a `NovaEditorJsData` model 124 | - More robust conversion between the model data and the Nova editor field 125 | 126 | ## Deprecated 127 | 128 | - `NovaEditorJs::make`, use `NovaEditorJsField::make` instead 129 | 130 | ## [2.0.3] - 2020-12-07 131 | 132 | ### Fixed 133 | 134 | - Fix for Amazon S3 file support (#49) 135 | 136 | ## [2.0.2] - 2020-11-29 137 | 138 | ### Changed 139 | 140 | - Reduced minimum height of editor (#47) 141 | 142 | ### Fixed 143 | 144 | - Fix for when using an S3 disk (#46) 145 | 146 | ## [2.0.0] - 2020-08-03 147 | 148 | ### Added 149 | 150 | - Added support for extending the EditorJS field with custom plugins 151 | 152 | --- 153 | 154 | For older changes before v2.0.0, please see the [releases page](https://github.com/advoor/nova-editor-js/releases). 155 | 156 | [unreleased]: https://github.com/advoor/nova-editor-js/compare/v3.3.0..master 157 | [4.0.0]: https://github.com/advoor/nova-editor-js/releases/v4.0.0 158 | [3.3.0]: https://github.com/advoor/nova-editor-js/releases/v3.3.0 159 | [3.2.1]: https://github.com/advoor/nova-editor-js/releases/v3.2.1 160 | [3.2.0]: https://github.com/advoor/nova-editor-js/releases/v3.2.0 161 | [3.1.0]: https://github.com/advoor/nova-editor-js/releases/v3.1.0 162 | [3.0.5]: https://github.com/advoor/nova-editor-js/releases/v3.0.5 163 | [3.0.4]: https://github.com/advoor/nova-editor-js/releases/v3.0.4 164 | [3.0.3]: https://github.com/advoor/nova-editor-js/releases/v3.0.3 165 | [3.0.2]: https://github.com/advoor/nova-editor-js/releases/v3.0.2 166 | [3.0.1]: https://github.com/advoor/nova-editor-js/releases/v3.0.1 167 | [3.0.0]: https://github.com/advoor/nova-editor-js/releases/v3.0.0 168 | [2.0.3]: https://github.com/advoor/nova-editor-js/releases/v2.0.3 169 | [2.0.2]: https://github.com/advoor/nova-editor-js/releases/v2.0.2 170 | [2.0.0]: https://github.com/advoor/nova-editor-js/releases/v2.0.0 171 | -------------------------------------------------------------------------------- /EXTENDING.md: -------------------------------------------------------------------------------- 1 | # Extending Nova EditorJS 2 | 3 | Extending NovaEditorJS is a bit of work, but shouldn't be too hard once you're known with Laravel. 4 | 5 | In this demonstration we will be incorporating the [warning component](https://github.com/editor-js/warning) in our 6 | Laravel application. 7 | 8 | There are two steps to extending the editor. The first consists of creating a JavaScript file and passing it onto Nova. 9 | The second step allows you to create a blade view file and pass it to the field to allow your block to render in the Nova `show` page. 10 | 11 | ## Creating the Javascript file 12 | 13 | `resources/js/editor-js-plugins/warning.js` 14 | 15 | ```js 16 | /* 17 | * The editorConfig variable is used by you to add your tools, 18 | * or any additional configuration you might want to add to the editor. 19 | * 20 | * The fieldConfig variable is the VueJS field exposed to you. You may 21 | * fetch any value that is contained in your laravel config file from there. 22 | */ 23 | NovaEditorJS.booting(function (editorConfig, fieldConfig) { 24 | if (fieldConfig.toolSettings.warning.activated === true) { 25 | editorConfig.tools.warning = { 26 | class: require("@editorjs/warning"), 27 | shortcut: fieldConfig.toolSettings.warning.shortcut, 28 | config: { 29 | titlePlaceholder: fieldConfig.toolSettings.warning.titlePlaceholder, 30 | messagePlaceholder: fieldConfig.toolSettings.warning.messagePlaceholder, 31 | }, 32 | }; 33 | } 34 | }); 35 | ``` 36 | 37 | `webpack.mix.js` 38 | 39 | ```js 40 | const mix = require("laravel-mix"); 41 | 42 | mix.js( 43 | "resources/js/editor-js-plugins/warning.js", 44 | "public/js/editor-js-plugins/warning.js" 45 | ); 46 | ``` 47 | 48 | `app/Providers/NovaServiceProvider.php` 49 | 50 | ```php 51 | // ... 52 | public function boot() 53 | { 54 | parent::boot(); 55 | 56 | Nova::serving(function () { 57 | Nova::script('editor-js-warning', public_path('js/editor-js-plugins/warning.js')); 58 | }); 59 | } 60 | // ... 61 | ``` 62 | 63 | `config/nova-editor-js.php` 64 | 65 | ```php 66 | return [ 67 | // ... 68 | 'toolSettings' => [ 69 | 'warning' => [ 70 | 'activated' => true, 71 | 'titlePlaceholder' => 'Title', 72 | 'messagePlaceholder' => 'Message', 73 | 'shortcut' => 'CMD+SHIFT+L' 74 | ], 75 | ] 76 | // ... 77 | ]; 78 | ``` 79 | 80 | ## Creating the blade view file 81 | 82 | `resources/views/editorjs/warning.blade.php` 83 | 84 | _CSS classes taken from [here](https://github.com/editor-js/warning/blob/master/src/index.css)._ 85 | 86 | ```html 87 |
88 |
89 |

{{ $title }}

90 |

{{ $message }}

91 |
92 |
93 | ``` 94 | 95 | `app/Providers/NovaServiceProvider.php` 96 | 97 | ```php 98 | use Advoor\NovaEditorJs\NovaEditorJs; 99 | 100 | // ... 101 | public function boot() 102 | { 103 | parent::boot(); 104 | 105 | NovaEditorJs::addRender('warning', function($block) { 106 | return view('editorjs.warning', $block['data'])->render(); 107 | }); 108 | 109 | // ... 110 | } 111 | // ... 112 | ``` 113 | 114 | That's it for extending the Nova EditorJS package! 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Nova Editor JS Field 2 | 3 | [![Latest Version on Github](https://img.shields.io/github/release/advoor/nova-editor-js.svg?style=flat-square)](https://packagist.org/packages/advoor/nova-editor-js) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/advoor/nova-editor-js.svg?style=flat-square)](https://packagist.org/packages/advoor/nova-editor-js) 5 | 6 | A Laravel Nova implementation of [Editor.js](https://github.com/codex-team/editor.js) 7 | by [@advoor](https://github.com/advoor). 8 | 9 | ## Installation 10 | 11 | Install via composer: 12 | 13 | ``` 14 | composer require advoor/nova-editor-js 15 | ``` 16 | 17 | Publish the config file 18 | 19 | ``` 20 | php artisan vendor:publish --provider="Advoor\NovaEditorJs\FieldServiceProvider" 21 | ``` 22 | 23 | ## Version Compatibility 24 | 25 | Laravel Nova 4.x isn't backwards compatible with 3.x, so we had to make a version split. 26 | Please use the below table to find which versions are suitable for your installation. 27 | 28 | | Package version | Nova Version | Laravel Version | PHP version | 29 | | --------------- | ------------ | --------------- | ----------- | 30 | | `4.x` | 4.x - 5.x | 10.x - 12.x | 8.2+ | 31 | | `3.x` | 4.x | 8.x - 10.x | 8.1+ | 32 | | `2.x` | 2.x - 3.x | 5.x - 8.x | 5.6 - 7.4 | 33 | 34 | Note that we really pushed the PHP version up. If you're staying on 35 | new versions of Laravel and Nova, we're expecting your PHP version to match that behaviour. 36 | 37 | ## Upgrade 38 | 39 | See [the upgrade guide](./UPGRADING.md). 40 | 41 | ## Usage 42 | 43 | To add EditorJS to your application, you'll need to modify your Nova resource. 44 | For ease-of-use we also recommend to update your models, but that's optional. 45 | 46 | ### Updating your Nova resource 47 | 48 | This package exposes a `NovaEditorJsField` that takes care of displaying the HTML contents 49 | and providing the user with the EditorJS field. 50 | 51 | To use it, simply import the field, 52 | 53 | ```php 54 | use Advoor\NovaEditorJs\NovaEditorJsField; 55 | ``` 56 | 57 | use it in your fields array, 58 | 59 | ```php 60 | return [ 61 | // … 62 | NovaEditorJsField::make('about'), 63 | ]; 64 | ``` 65 | 66 | And boom, you've got yourself a fancy editor. 67 | 68 | ### Updating your models (optional) 69 | 70 | For ease-of-use, we recommend you add the `NovaEditorJsCast` to the `$casts` on your models. 71 | This will map the value to a `NovaEditorJsData` model, which can be returned in Blade (rendering HTML), or sent 72 | via API calls (rendering JSON, unless you call `toHtml` on it or cast it to a string). 73 | 74 | ```php 75 | use Advoor\NovaEditorJs\NovaEditorJsCast; 76 | 77 | class User extends Model { 78 | protected $casts = [ 79 | 'about' => NovaEditorJsCast::class, 80 | ]; 81 | } 82 | ``` 83 | 84 | Since the `NovaEditorJsData` model is an `Htmlable`, Blade will recognize it as 85 | safe HTML. This means you don't have to use Blade "unescaped statements". 86 | 87 | ```blade 88 |
89 |

About {{ $user->name }}

90 | {{ $user->about }} 91 |
92 | ``` 93 | 94 | ### Rendering HTML without model changes 95 | 96 | You can also use the `NovaEditorJs` facade to render HTML from stored data. 97 | 98 | ```php 99 | NovaEditorJs::generateHtmlOutput($user->about); 100 | ``` 101 | 102 | The return value of `generateHtmlOutput` is an `HtmlString`, which is treated as 103 | safe by Blade. This means you don't have to use Blade "unescaped statements". 104 | 105 | ```blade 106 |
107 |

About {{ $user->name }}

108 | {{ NovaEditorJs::generateHtmlOutput($user->about) }} 109 |
110 | ``` 111 | 112 | ## Customizing 113 | 114 | You can configure the editor settings and what tools the Editor should use, by 115 | updating the `editorSettings` and `toolSettings` property in the config file 116 | respectively. 117 | 118 | From the config, you can define the following editor settings: 119 | 120 | - `placeholder` ([docs][placeholder-docs]) - The placeholder to show in an empty editor 121 | - `defaultBlock` ([docs][defaultblock-docs]) - The block that's used by default 122 | - `autofocus` ([docs][autofocus-docs]) - If the editor should auto-focus, only use if you never have multiple editors on 123 | a page and after considering the 124 | [accessibility implications][autofocus-accessibility] 125 | - `rtl` ([docs][rtl-docs]) - Set to true to enable right-to-left mode, for languages like Arabic and Hebrew 126 | 127 | [placeholder-docs]: https://editorjs.io/configuration#placeholder 128 | [defaultblock-docs]: https://editorjs.io/configuration#change-the-default-block 129 | [autofocus-docs]: https://editorjs.io/configuration#autofocus 130 | [autofocus-accessibility]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus#accessibility_considerations 131 | [rtl-docs]: https://editorjs.io/i18n#rtl-support 132 | 133 | Furthermore, you can customize the tools the editor should use. The following tools are enabled by default: 134 | 135 | - [Header](https://github.com/editor-js/header) 136 | - [Image](https://github.com/editor-js/image) 137 | - [Link](https://github.com/editor-js/link) 138 | - [List](https://github.com/editor-js/list) 139 | - [Code block](https://github.com/editor-js/code) 140 | - [Inline code](https://github.com/editor-js/inline-code) 141 | - [Checklist](https://github.com/editor-js/checklist) 142 | - [Marker](https://github.com/editor-js/marker) 143 | - [Embeds](https://github.com/editor-js/embed) 144 | - [Delimiter](https://github.com/editor-js/delimiter) 145 | - [Table](https://github.com/editor-js/table) 146 | - [Raw](https://github.com/editor-js/raw) 147 | 148 | You can customize the views for each component, by changing the view in `resources/views/vendor/nova-editor-js/`. 149 | 150 | The _Embeds_ tool is triggered by pasting URLs to embeddable 151 | content. It does not have an entry in the "Add" menu. 152 | 153 | ### Registering custom components 154 | 155 | Please refer to the [extending Nova EditorJS](./EXTENDING.md) guide on instructions on how to register custom 156 | components. 157 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | This guide describes how to upgrade this application. 4 | 5 | ## 3.x to 4.x 6 | 7 | The following deprecations from 3.x have been removed in 4.x: 8 | 9 | - `Advoor\NovaEditorJs\NovaEditorJs::make`, use `Advoor\NovaEditorJs\NovaEditorJsField::make` instead 10 | - Config setting `editorSettings.initialBlock`, use `editorSettings.defaultBlock` instead 11 | - Support for Laravel 8.x and Laravel 9.x has been dropped. 12 | 13 | ## From 2.x to 3.x (Laravel Nova 4.x) 14 | 15 | To be more in line with the separation of concerns, a bunch of code has moved. 16 | The changes are somewhat backwards compatible, but you're advices to quickly fix these deprecations. 17 | 18 | ### High impact changes 19 | 20 | - The HTML rendering has been split from the field, `NovaEditorJs::make` is deprecated. 21 | - Update your Nova resources to use the `NovaEditorJsField` in the `fields()` 22 | - `NovaEditorJs` is now a facade, containing the `generateHtmlOutput` and `addRender` methods 23 | - PHP requirement is now 8.1+ 24 | - Laravel requirement is now 8.0+ 25 | 26 | ### Medium impact changes 27 | 28 | - `NovaEditorJsField::displayUsing` now recieves a `NovaEditorJsData` instance, instead of a `string|array`. 29 | - `NovaEditorJsData` is a Fluent type, can be treated as an `iterable`. 30 | 31 | ### Low impact changes 32 | 33 | - The Table component has been updated. While this shouldn't affect the data model, you're best off checking it. 34 | - Using the `NovaEditorJsCast` on your Eloquent models is now recommended over casting fields to an array. 35 | 36 | ## From 1.x to 2.x 37 | 38 | _No significant changes written down._ 39 | 40 | ## From 0.4 to 1.x 41 | 42 | If upgrading from v0.4.0, re-publish the config file! 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advoor/nova-editor-js", 3 | "description": "A Laravel Nova field bringing EditorJs magic to Nova.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "nova", 8 | "editor", 9 | "editorjs", 10 | "wysiwyg" 11 | ], 12 | "require": { 13 | "php": "^8.2", 14 | "ext-exif": "*", 15 | "ext-json": "*", 16 | "codex-team/editor.js": "*", 17 | "guzzlehttp/guzzle": "^7.0", 18 | "illuminate/events": "^10.0 || ^11.0 || ^12.0", 19 | "illuminate/support": "^10.0 || ^11.0 || ^12.0", 20 | "laravel/laravel": "^12", 21 | "laravel/nova": "^4.0 || ^5.0", 22 | "spatie/image": "^3.0" 23 | }, 24 | "require-dev": { 25 | "laravel/pint": "^1.15", 26 | "orchestra/testbench": "^10", 27 | "php-parallel-lint/php-parallel-lint": "^1.3" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Advoor\\NovaEditorJs\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Tests\\": "tests" 37 | }, 38 | "files": [ 39 | "tests/helpers.php" 40 | ] 41 | }, 42 | "scripts": { 43 | "test": "phpunit", 44 | "lint": [ 45 | "parallel-lint --exclude .git --exclude vendor ." 46 | ], 47 | "format": [ 48 | "pint" 49 | ] 50 | }, 51 | "scripts-descriptions": { 52 | "test": "Test application using PHPUnit.", 53 | "lint": "Lint all php files", 54 | "format": "Run php-cs-fixer formatter" 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Advoor\\NovaEditorJs\\FieldServiceProvider" 60 | ] 61 | } 62 | }, 63 | "config": { 64 | "sort-packages": true 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true, 68 | "repositories": [ 69 | { 70 | "type": "composer", 71 | "url": "https://nova.laravel.com" 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /dist/css/field.css: -------------------------------------------------------------------------------- 1 | .editor-js{background-color:#fff;border-color:#bacad6;border-radius:.5rem;border-width:1px;box-shadow:0 2px 4px 0 rgba(0,0,0,.05);color:#7c858e;padding-left:.75rem;width:100%}.editor-js h1{font-size:26px;font-weight:400;margin-bottom:1.35em}.editor-js h2{font-size:21px;font-weight:400}.editor-js h3{font-size:20px;font-weight:400}.editor-js h4{font-size:19px;font-weight:400}.editor-js-content .editor-js-block{padding:.7em 0}.editor-js-content h2{line-height:1.5em;margin:0 0 -.9em;padding:1em 0}.editor-js-content p{line-height:1.6em}.editor-js-content li{line-height:1.6em;padding:5.5px 0 5.5px 3px}.editor-js-content .editor-js-code{word-wrap:normal;background:#f8f7fa;border:1px solid #f1f1f4;box-shadow:none;color:#41314e;font-size:12px;line-height:1.6em;min-height:200px;overflow-x:auto;resize:vertical;white-space:pre}.editor-js-content .editor-js-link{background:#fff;border:1px solid hsla(240,3%,79%,.48);border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.1);display:block;padding:25px}.editor-js-content .editor-js-link h4{font-size:17px;font-weight:600;line-height:1.5em;margin:0 0 10px}.editor-js-content .editor-js-link small{color:#888;display:block;font-size:15px;line-height:1em;margin-top:25px}.editor-js-content .editor-js-link .editor-js-link-image{background-position:50%;background-repeat:no-repeat;background-size:cover;border-radius:3px;float:right;height:65px;margin:0 0 0 30px;width:65px}.editor-js-content .editor-js-checklist .checklist-item{box-sizing:content-box;display:flex;padding:0 10px}.editor-js-content .editor-js-checklist .checklist-item .checkbox{background:#fff;border:1px solid #d0d0d0;border-radius:50%;display:inline-block;flex-shrink:0;height:20px;margin:10px 10px 10px 0;position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:20px}.editor-js-content .editor-js-checklist .checklist-item .checkbox:after{background:transparent;border:2px solid #fcfff4;border-right:none;border-top:none;content:"";height:5px;left:5px;opacity:1;position:absolute;top:5px;transform:rotate(-45deg);width:8px}.editor-js-content .editor-js-checklist .checklist-item .checkbox-checked{background:#388ae5;border-color:#388ae5}.editor-js-content .editor-js-checklist .checklist-item .checkbox-text{flex-grow:1;outline:none;padding:10px 0}.editor-js-content .editor-js-delimiter{line-height:1.6em;text-align:center;width:100%}.editor-js-content .editor-js-delimiter:before{content:"***";display:inline-block;font-size:30px;height:30px;letter-spacing:.2em;line-height:65px}.editor-js-content .editor-js-table{border-collapse:collapse;height:100%;table-layout:fixed;width:100%}.editor-js-content .editor-js-table td{border:1px solid #dbdbe2;padding:10px;vertical-align:top} 2 | -------------------------------------------------------------------------------- /dist/js/field.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * vuex v4.0.2 3 | * (c) 2021 Evan You 4 | * @license MIT 5 | */ 6 | 7 | /*! For license information please see editor.js.LICENSE.txt */ 8 | 9 | /** 10 | * @license 11 | * Lodash 12 | * Copyright OpenJS Foundation and other contributors 13 | * Released under MIT license 14 | * Based on Underscore.js 1.8.3 15 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 16 | */ 17 | 18 | /** 19 | * Base Paragraph Block for the Editor.js. 20 | * Represents simple paragraph 21 | * 22 | * @author CodeX (team@codex.so) 23 | * @copyright CodeX 2018 24 | * @license The MIT License (MIT) 25 | */ 26 | 27 | /** 28 | * CodeTool for Editor.js 29 | * 30 | * @author CodeX (team@ifmo.su) 31 | * @copyright CodeX 2018 32 | * @license MIT 33 | * @version 2.0.0 34 | */ 35 | 36 | /** 37 | * Delimiter Block for the Editor.js. 38 | * 39 | * @author CodeX (team@ifmo.su) 40 | * @copyright CodeX 2018 41 | * @license The MIT License (MIT) 42 | * @version 2.0.0 43 | */ 44 | 45 | /** 46 | * Header block for the Editor.js. 47 | * 48 | * @author CodeX (team@ifmo.su) 49 | * @copyright CodeX 2018 50 | * @license MIT 51 | * @version 2.0.0 52 | */ 53 | 54 | /** 55 | * Image Tool for the Editor.js 56 | * 57 | * @author CodeX 58 | * @license MIT 59 | * @see {@link https://github.com/editor-js/image} 60 | * 61 | * To developers. 62 | * To simplify Tool structure, we split it to 4 parts: 63 | * 1) index.js — main Tool's interface, public API and methods for working with data 64 | * 2) uploader.js — module that has methods for sending files via AJAX: from device, by URL or File pasting 65 | * 3) ui.js — module for UI manipulations: render, showing preloader, etc 66 | * 4) tunes.js — working with Block Tunes: render buttons, handle clicks 67 | * 68 | * For debug purposes there is a testing server 69 | * that can save uploaded files and return a Response {@link UploadResponseFormat} 70 | * 71 | * $ node dev/server.js 72 | * 73 | * It will expose 8008 port, so you can pass http://localhost:8008 with the Tools config: 74 | * 75 | * image: { 76 | * class: ImageTool, 77 | * config: { 78 | * endpoints: { 79 | * byFile: 'http://localhost:8008/uploadFile', 80 | * byUrl: 'http://localhost:8008/fetchUrl', 81 | * } 82 | * }, 83 | * }, 84 | */ 85 | 86 | /** 87 | * Raw HTML Tool for CodeX Editor 88 | * 89 | * @author CodeX (team@codex.so) 90 | * @copyright CodeX 2018 91 | * @license The MIT License (MIT) 92 | */ 93 | -------------------------------------------------------------------------------- /dist/js/index.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * vuex v4.0.2 3 | * (c) 2021 Evan You 4 | * @license MIT 5 | */ 6 | 7 | /*! For license information please see editor.js.LICENSE.txt */ 8 | 9 | /** 10 | * @license 11 | * Lodash 12 | * Copyright OpenJS Foundation and other contributors 13 | * Released under MIT license 14 | * Based on Underscore.js 1.8.3 15 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 16 | */ 17 | 18 | /** 19 | * Base Paragraph Block for the Editor.js. 20 | * Represents simple paragraph 21 | * 22 | * @author CodeX (team@codex.so) 23 | * @copyright CodeX 2018 24 | * @license The MIT License (MIT) 25 | */ 26 | 27 | /** 28 | * CodeTool for Editor.js 29 | * 30 | * @author CodeX (team@ifmo.su) 31 | * @copyright CodeX 2018 32 | * @license MIT 33 | * @version 2.0.0 34 | */ 35 | 36 | /** 37 | * Delimiter Block for the Editor.js. 38 | * 39 | * @author CodeX (team@ifmo.su) 40 | * @copyright CodeX 2018 41 | * @license The MIT License (MIT) 42 | * @version 2.0.0 43 | */ 44 | 45 | /** 46 | * Header block for the Editor.js. 47 | * 48 | * @author CodeX (team@ifmo.su) 49 | * @copyright CodeX 2018 50 | * @license MIT 51 | * @version 2.0.0 52 | */ 53 | 54 | /** 55 | * Image Tool for the Editor.js 56 | * 57 | * @author CodeX 58 | * @license MIT 59 | * @see {@link https://github.com/editor-js/image} 60 | * 61 | * To developers. 62 | * To simplify Tool structure, we split it to 4 parts: 63 | * 1) index.js — main Tool's interface, public API and methods for working with data 64 | * 2) uploader.js — module that has methods for sending files via AJAX: from device, by URL or File pasting 65 | * 3) ui.js — module for UI manipulations: render, showing preloader, etc 66 | * 4) tunes.js — working with Block Tunes: render buttons, handle clicks 67 | * 68 | * For debug purposes there is a testing server 69 | * that can save uploaded files and return a Response {@link UploadResponseFormat} 70 | * 71 | * $ node dev/server.js 72 | * 73 | * It will expose 8008 port, so you can pass http://localhost:8008 with the Tools config: 74 | * 75 | * image: { 76 | * class: ImageTool, 77 | * config: { 78 | * endpoints: { 79 | * byFile: 'http://localhost:8008/uploadFile', 80 | * byUrl: 'http://localhost:8008/fetchUrl', 81 | * } 82 | * }, 83 | * }, 84 | */ 85 | 86 | /** 87 | * Raw HTML Tool for CodeX Editor 88 | * 89 | * @author CodeX (team@codex.so) 90 | * @copyright CodeX 2018 91 | * @license The MIT License (MIT) 92 | */ 93 | -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js", 3 | "/css/field.css": "/css/field.css" 4 | } 5 | -------------------------------------------------------------------------------- /nova.mix.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies,class-methods-use-this */ 2 | 3 | const mix = require('laravel-mix'); 4 | const webpack = require('webpack'); 5 | const path = require('path'); 6 | const fs = require('fs'); 7 | 8 | const novaLocation = [ 9 | path.join(__dirname, '../../vendor/laravel/nova/resources/js/mixins/packages.js'), 10 | path.join(__dirname, 'vendor/laravel/nova/resources/js/mixins/packages.js'), 11 | ].filter(fs.existsSync)[0] ?? ''; 12 | 13 | if (!novaLocation) { 14 | throw new Error('Unable to locate Nova resources. Mount the extension in a Laravel installation, or run `composer install` in the extension directory.'); 15 | } 16 | 17 | class NovaExtension { 18 | name() { 19 | return 'nova-extension'; 20 | } 21 | 22 | register(name) { 23 | this.name = name; 24 | } 25 | 26 | webpackPlugins() { 27 | return new webpack.ProvidePlugin({ 28 | _: 'lodash', 29 | Errors: 'form-backend-validation', 30 | }); 31 | } 32 | 33 | webpackConfig(webpackConfig) { 34 | webpackConfig.externals = { 35 | vue: 'Vue', 36 | }; 37 | 38 | webpackConfig.resolve.alias = { 39 | ...(webpackConfig.resolve.alias || {}), 40 | 'laravel-nova': novaLocation, 41 | }; 42 | 43 | webpackConfig.output = { 44 | uniqueName: this.name, 45 | }; 46 | } 47 | } 48 | 49 | mix.extend('nova', new NovaExtension()); 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix build", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix build --production", 11 | "format": "eslint --fix resources/js/**/*.{js,vue} *.js && prettier --write *.md", 12 | "nova:install": "npm --prefix='./vendor/laravel/nova' ci" 13 | }, 14 | "dependencies": { 15 | "@editorjs/checklist": "^1.3.0", 16 | "@editorjs/code": "^2.7.0", 17 | "@editorjs/delimiter": "^1.2.0", 18 | "@editorjs/editorjs": "^2.24.3", 19 | "@editorjs/embed": "^2.5.1", 20 | "@editorjs/header": "^2.6.2", 21 | "@editorjs/image": "^2.6.2", 22 | "@editorjs/inline-code": "^1.3.1", 23 | "@editorjs/link": "^2.4.1", 24 | "@editorjs/list": "^1.7.0", 25 | "@editorjs/marker": "^1.2.2", 26 | "@editorjs/paragraph": "^2.8.0", 27 | "@editorjs/raw": "^2.3.1", 28 | "@editorjs/table": "^2.0.2" 29 | }, 30 | "devDependencies": { 31 | "@vue/compiler-sfc": "^3.2.36", 32 | "eslint": "^8.16.0", 33 | "eslint-config-airbnb-base": "^15.0.0", 34 | "eslint-plugin-vue": "^9.1.0", 35 | "laravel-mix": "^6.0.44", 36 | "postcss": "^8.4.14", 37 | "prettier": "^2.7.1", 38 | "vue-loader": "^16.8.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Feature 6 | 7 | 8 | ./tests/Unit 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ./src 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "php_unit_method_casing": { 5 | "case": "camel_case" 6 | }, 7 | "php_unit_test_annotation": { 8 | "style": "prefix" 9 | }, 10 | "no_superfluous_phpdoc_tags": true, 11 | "declare_strict_types": true, 12 | "strict_param": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /resources/css/field.css: -------------------------------------------------------------------------------- 1 | /* Nova Tool CSS */ 2 | .editor-js { 3 | width: 100%; 4 | background-color: #fff; 5 | border-width: 1px; 6 | border-color: #bacad6; 7 | padding-left: .75rem; 8 | color: #7c858e; 9 | border-radius: .5rem; 10 | -webkit-box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .05); 11 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .05); 12 | } 13 | 14 | .editor-js h1 { 15 | font-size: 26px; 16 | margin-bottom: 1.35em; 17 | font-weight: 400; 18 | } 19 | 20 | .editor-js h2 { 21 | font-size: 21px; 22 | font-weight: 400; 23 | } 24 | 25 | .editor-js h3 { 26 | font-size: 20px; 27 | font-weight: 400; 28 | } 29 | 30 | .editor-js h4 { 31 | font-size: 19px; 32 | font-weight: 400; 33 | } 34 | 35 | .editor-js-content .editor-js-block { 36 | padding: .7em 0; 37 | } 38 | 39 | .editor-js-content h2 { 40 | padding: 1em 0; 41 | margin: 0; 42 | margin-bottom: -0.9em; 43 | line-height: 1.5em; 44 | } 45 | 46 | .editor-js-content p { 47 | line-height: 1.6em; 48 | } 49 | 50 | .editor-js-content li { 51 | padding: 5.5px 0 5.5px 3px; 52 | line-height: 1.6em; 53 | } 54 | 55 | .editor-js-content .editor-js-code { 56 | min-height: 200px; 57 | color: #41314e; 58 | line-height: 1.6em; 59 | font-size: 12px; 60 | background: #f8f7fa; 61 | border: 1px solid #f1f1f4; 62 | -webkit-box-shadow: none; 63 | box-shadow: none; 64 | white-space: pre; 65 | word-wrap: normal; 66 | overflow-x: auto; 67 | resize: vertical; 68 | } 69 | 70 | .editor-js-content .editor-js-link { 71 | display: block; 72 | background: #fff; 73 | border: 1px solid rgba(201, 201, 204, 0.48); 74 | box-shadow: 0 1px 3px rgba(0, 0, 0, .1); 75 | border-radius: 6px; 76 | padding: 25px; 77 | } 78 | 79 | .editor-js-content .editor-js-link h4 { 80 | font-size: 17px; 81 | font-weight: 600; 82 | line-height: 1.5em; 83 | margin: 0 0 10px 0; 84 | } 85 | 86 | .editor-js-content .editor-js-link small { 87 | margin-top: 25px; 88 | display: block; 89 | font-size: 15px; 90 | line-height: 1em; 91 | color: #888; 92 | } 93 | 94 | .editor-js-content .editor-js-link .editor-js-link-image { 95 | background-position: center center; 96 | background-repeat: no-repeat; 97 | background-size: cover; 98 | margin: 0 0 0 30px; 99 | width: 65px; 100 | height: 65px; 101 | border-radius: 3px; 102 | float: right; 103 | } 104 | 105 | .editor-js-content .editor-js-checklist .checklist-item { 106 | display: flex; 107 | padding: 0 10px; 108 | box-sizing: content-box; 109 | } 110 | 111 | .editor-js-content .editor-js-checklist .checklist-item .checkbox { 112 | display: inline-block; 113 | flex-shrink: 0; 114 | position: relative; 115 | width: 20px; 116 | height: 20px; 117 | margin: 10px 10px 10px 0; 118 | border-radius: 50%; 119 | border: 1px solid #d0d0d0; 120 | background: #fff; 121 | user-select: none; 122 | } 123 | 124 | .editor-js-content .editor-js-checklist .checklist-item .checkbox::after { 125 | position: absolute; 126 | top: 5px; 127 | left: 5px; 128 | width: 8px; 129 | height: 5px; 130 | border: 2px solid #fcfff4; 131 | border-top: none; 132 | border-right: none; 133 | background: transparent; 134 | content: ''; 135 | opacity: 1; 136 | transform: rotate(-45deg); 137 | } 138 | 139 | .editor-js-content .editor-js-checklist .checklist-item .checkbox-checked { 140 | background: #388ae5; 141 | border-color: #388ae5; 142 | 143 | 144 | } 145 | 146 | .editor-js-content .editor-js-checklist .checklist-item .checkbox-text { 147 | outline: none; 148 | flex-grow: 1; 149 | padding: 10px 0; 150 | } 151 | 152 | .editor-js-content .editor-js-delimiter { 153 | line-height: 1.6em; 154 | width: 100%; 155 | text-align: center; 156 | } 157 | 158 | .editor-js-content .editor-js-delimiter::before { 159 | display: inline-block; 160 | content: "***"; 161 | font-size: 30px; 162 | line-height: 65px; 163 | height: 30px; 164 | letter-spacing: 0.2em; 165 | } 166 | 167 | .editor-js-content .editor-js-table { 168 | width: 100%; 169 | height: 100%; 170 | border-collapse: collapse; 171 | table-layout: fixed; 172 | } 173 | 174 | .editor-js-content .editor-js-table td { 175 | border: 1px solid #dbdbe2; 176 | padding: 10px; 177 | vertical-align: top; 178 | } 179 | -------------------------------------------------------------------------------- /resources/js/blocks/checklist.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.checklist.activated === true) { 3 | editorConfig.tools.checklist = { 4 | class: require('@editorjs/checklist'), 5 | inlineToolbar: fieldConfig.toolSettings.checklist.inlineToolbar, 6 | shortcut: fieldConfig.toolSettings.checklist.shortcut, 7 | }; 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /resources/js/blocks/code.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.code.activated === true) { 3 | editorConfig.tools.code = { 4 | class: require('@editorjs/code'), 5 | shortcut: fieldConfig.toolSettings.code.shortcut, 6 | config: { 7 | placeholder: fieldConfig.toolSettings.code.placeholder, 8 | }, 9 | }; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /resources/js/blocks/delimiter.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.delimiter.activated === true) { 3 | editorConfig.tools.delimiter = { 4 | class: require('@editorjs/delimiter'), 5 | }; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /resources/js/blocks/embed.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.embed.activated === true) { 3 | editorConfig.tools.embed = { 4 | class: require('@editorjs/embed'), 5 | inlineToolbar: fieldConfig.toolSettings.embed.inlineToolbar, 6 | config: { 7 | services: { 8 | codepen: fieldConfig.toolSettings.embed.services.codepen, 9 | imgur: fieldConfig.toolSettings.embed.services.imgur, 10 | vimeo: fieldConfig.toolSettings.embed.services.vimeo, 11 | youtube: fieldConfig.toolSettings.embed.services.youtube, 12 | }, 13 | }, 14 | }; 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /resources/js/blocks/heading.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.header.activated === true) { 3 | editorConfig.tools.header = { 4 | class: require('@editorjs/header'), 5 | config: { 6 | placeholder: fieldConfig.toolSettings.header.placeholder, 7 | }, 8 | shortcut: fieldConfig.toolSettings.header.shortcut, 9 | }; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /resources/js/blocks/image.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.image.activated === true) { 3 | editorConfig.tools.image = { 4 | class: require('@editorjs/image'), 5 | config: { 6 | endpoints: { 7 | byFile: fieldConfig.uploadImageByFileEndpoint, 8 | byUrl: fieldConfig.uploadImageByUrlEndpoint, 9 | }, 10 | additionalRequestHeaders: { 11 | 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 12 | }, 13 | }, 14 | }; 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /resources/js/blocks/inline-code.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.inlineCode.activated === true) { 3 | editorConfig.tools.inlineCode = { 4 | class: require('@editorjs/inline-code'), 5 | shortcut: fieldConfig.toolSettings.inlineCode.shortcut, 6 | }; 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /resources/js/blocks/link.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.link.activated === true) { 3 | editorConfig.tools.linkTool = { 4 | class: require('@editorjs/link'), 5 | config: { 6 | endpoint: fieldConfig.fetchUrlEndpoint, 7 | }, 8 | }; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /resources/js/blocks/list.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.list.activated === true) { 3 | editorConfig.tools.list = { 4 | class: require('@editorjs/list'), 5 | inlineToolbar: fieldConfig.toolSettings.list.inlineToolbar, 6 | shortcut: fieldConfig.toolSettings.list.shortcut, 7 | }; 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /resources/js/blocks/marker.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.marker.activated === true) { 3 | editorConfig.tools.marker = { 4 | class: require('@editorjs/marker'), 5 | shortcut: fieldConfig.toolSettings.marker.shortcut, 6 | }; 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /resources/js/blocks/paragraph.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig) => { 2 | editorConfig.tools.paragraph = { 3 | class: require('@editorjs/paragraph'), 4 | inlineToolbar: true, 5 | }; 6 | }); 7 | -------------------------------------------------------------------------------- /resources/js/blocks/raw.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.raw.activated === true) { 3 | editorConfig.tools.raw = { 4 | class: require('@editorjs/raw'), 5 | config: { 6 | placeholder: fieldConfig.toolSettings.raw.placeholder, 7 | }, 8 | }; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /resources/js/blocks/table.js: -------------------------------------------------------------------------------- 1 | NovaEditorJS.booting((editorConfig, fieldConfig) => { 2 | if (fieldConfig.toolSettings.table.activated === true) { 3 | editorConfig.tools.table = { 4 | class: require('@editorjs/table'), 5 | inlineToolbar: fieldConfig.toolSettings.table.inlineToolbar, 6 | }; 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /resources/js/components/DetailField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /resources/js/components/FormField.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 109 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | Nova.booting((Vue) => { 2 | Vue.component('IndexNovaEditorJs', require('./components/IndexField').default); 3 | Vue.component('DetailNovaEditorJs', require('./components/DetailField').default); 4 | Vue.component('FormNovaEditorJs', require('./components/FormField').default); 5 | }); 6 | -------------------------------------------------------------------------------- /resources/js/index.js: -------------------------------------------------------------------------------- 1 | // Import the Nova Editor class 2 | import NovaEditorJS from './nova-editor'; 3 | 4 | // Expose it for other plugins 5 | window.NovaEditorJS = new NovaEditorJS(); 6 | 7 | // Import the blocks 8 | require('./blocks/checklist'); 9 | require('./blocks/code'); 10 | require('./blocks/delimiter'); 11 | require('./blocks/embed'); 12 | require('./blocks/heading'); 13 | require('./blocks/image'); 14 | require('./blocks/inline-code'); 15 | require('./blocks/link'); 16 | require('./blocks/list'); 17 | require('./blocks/marker'); 18 | require('./blocks/paragraph'); 19 | require('./blocks/raw'); 20 | require('./blocks/table'); 21 | 22 | // Import the Nova field declaration 23 | require('./field'); 24 | -------------------------------------------------------------------------------- /resources/js/nova-editor.js: -------------------------------------------------------------------------------- 1 | const EditorJS = require('@editorjs/editorjs'); 2 | 3 | export default class NovaEditorJS { 4 | constructor() { 5 | this.defaultConfigObject = { 6 | tools: {}, 7 | }; 8 | 9 | this.persistentConfigObject = {}; 10 | this.bootingCallbacks = []; 11 | } 12 | 13 | /** 14 | * Callback for registering a plugin 15 | * 16 | * @callback novaEditorJSBooting 17 | * @param {Object} editorConfig Editor Config 18 | * @param {Object} fieldConfig Field Config 19 | */ 20 | 21 | /** 22 | * Register a callback to load your block plugin. 23 | * 24 | * @param {novaEditorJSBooting} callback Callback to register your plugin 25 | */ 26 | booting(callback) { 27 | // Only callables are allowed 28 | if (!(callback instanceof Function)) { 29 | return; 30 | } 31 | 32 | this.bootingCallbacks.push(callback); 33 | } 34 | 35 | getInstance(config, field) { 36 | const editorConfig = _.merge({}, this.defaultConfigObject, config); 37 | const fieldObject = _.cloneDeep(field); 38 | 39 | // Plugins should not modify the field config. 40 | // If a key should be changed, other plugins loaded later 41 | // would have an unsynchronized version of the field configuration. 42 | Object.freeze(fieldObject); 43 | 44 | // We boot each block plugin by passing the editorConfig and the fieldObject 45 | this.bootingCallbacks.forEach((callback) => callback(editorConfig, fieldObject)); 46 | 47 | // We apply the persistent config and return the editor instance 48 | return new EditorJS( 49 | _.merge(editorConfig, this.persistentConfigObject), 50 | ); 51 | } 52 | 53 | /** 54 | * Sets a default configuration for the editor. The values set here will 55 | * be overriden by the form field, if a key of the same name exists. 56 | * 57 | * @param config 58 | */ 59 | defaultConfig(config) { 60 | // If it's not an object, we discard the information 61 | if (!(config instanceof Object)) { 62 | return; 63 | } 64 | 65 | // We use lodash to perform a deep merge, instead of overwriting 66 | // root values with the spread operator 67 | this.defaultConfigObject = _.merge(this.defaultConfigObject, config); 68 | } 69 | 70 | /** 71 | * Sets a persistent configuration for the editor. The values set here will 72 | * be overwrite any and all keys set by the form field, if a key of the 73 | * same name exists. 74 | * 75 | * @param config 76 | */ 77 | persistentConfig(config) { 78 | // If it's not an object, we discard the information 79 | if (!(config instanceof Object)) { 80 | return; 81 | } 82 | 83 | // We use lodash to perform a deep merge, instead of overwriting 84 | // root values with the spread operator 85 | this.persistentConfigObject = _.merge(this.persistentConfigObject, config); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /resources/views/checklist.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @foreach ($items as $item) 3 |
4 |
5 | {{ $item['text'] }} 6 |
7 |
8 | @endforeach 9 |
10 | -------------------------------------------------------------------------------- /resources/views/code.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {{ $code }} 3 |
4 | -------------------------------------------------------------------------------- /resources/views/content.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! $content !!} 3 |
4 | -------------------------------------------------------------------------------- /resources/views/delimiter.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | -------------------------------------------------------------------------------- /resources/views/embed.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | 10 |
11 | {{ $caption }} 12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /resources/views/heading.blade.php: -------------------------------------------------------------------------------- 1 |
2 | <{{ "h{$level}" }}> 3 | {{ $text }} 4 | 5 |
6 | -------------------------------------------------------------------------------- /resources/views/image.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ $caption }} 4 | @if (!empty($caption)) 5 | {{ $caption }} 6 | @endif 7 |
8 |
9 | -------------------------------------------------------------------------------- /resources/views/link.blade.php: -------------------------------------------------------------------------------- 1 | @php($imageUrl = $meta['imageUrl'] ?? Arr::get($meta, 'image.url')) 2 | 19 | -------------------------------------------------------------------------------- /resources/views/list.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {!! ($style == 'unordered') ? '
    ' : '
      ' !!} 3 | @foreach ($items as $item) 4 |
    1. 5 | {!! $item !!} 6 |
    2. 7 | @endforeach 8 | {!! ($style == 'unordered') ? '
' : '' !!} 9 |
10 | -------------------------------------------------------------------------------- /resources/views/paragraph.blade.php: -------------------------------------------------------------------------------- 1 |
2 |

3 | {!! $text !!} 4 |

5 |
6 | -------------------------------------------------------------------------------- /resources/views/raw.blade.php: -------------------------------------------------------------------------------- 1 | {!! $html !!} 2 | -------------------------------------------------------------------------------- /resources/views/table.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | @foreach ($content as $row) 4 | 5 | @foreach ($row as $content) 6 | 9 | @endforeach 10 | 11 | @endforeach 12 |
7 | {!! $content !!} 8 |
13 |
14 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | name('editor-js-upload-image-by-file'); 10 | Route::post('upload/url', EditorJsImageUploadController::class.'@url')->name('editor-js-upload-image-by-url'); 11 | Route::get('fetch/url', EditorJsLinkController::class.'@fetch')->name('editor-js-fetch-url'); 12 | -------------------------------------------------------------------------------- /src/Events/EditorJsImageUploaded.php: -------------------------------------------------------------------------------- 1 | app->booted(function () { 26 | $this->routes(); 27 | }); 28 | 29 | $this->publishes([ 30 | __DIR__.'/config/nova-editor-js.php' => base_path('config/nova-editor-js.php'), 31 | ], 'editorjs-config'); 32 | 33 | $this->publishes([ 34 | __DIR__.'/../resources/views' => resource_path('views/vendor/nova-editor-js'), 35 | ], 'views'); 36 | 37 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'nova-editor-js'); 38 | 39 | Nova::serving(function (ServingNova $event) { 40 | Nova::script('nova-editor-js', __DIR__.'/../dist/js/field.js'); 41 | Nova::style('nova-editor-js', __DIR__.'/../dist/css/field.css'); 42 | }); 43 | 44 | if (! $this->app->configurationIsCached() && ! $this->app->isProduction()) { 45 | $this->checkForConfigDeprecations(); 46 | } 47 | } 48 | 49 | /** 50 | * Register the fields's routes. 51 | * 52 | * @return void 53 | */ 54 | protected function routes() 55 | { 56 | if ($this->app->routesAreCached()) { 57 | return; 58 | } 59 | 60 | Route::middleware(['nova']) 61 | ->prefix('nova-vendor/editor-js-field') 62 | ->group(__DIR__.'/../routes/api.php'); 63 | } 64 | 65 | /** 66 | * Register any application services. 67 | * 68 | * @return void 69 | */ 70 | public function register() 71 | { 72 | // Register the converter 73 | $this->app->singleton(NovaEditorJsConverter::class); 74 | $this->app->alias(NovaEditorJsConverter::class, 'nova-editor-js'); 75 | } 76 | 77 | /** 78 | * Check for deprecated config keys. 79 | */ 80 | protected function checkForConfigDeprecations(): void 81 | { 82 | /** @var ConfigRepository $config */ 83 | $config = $this->app->get('config'); 84 | if ($config->has('nova-editor-js.editorSettings.initialBlock')) { 85 | trigger_deprecation('advoor/nova-editor-js', '3.1.0', 'The config key "editorSettings.initialBlock" is deprecated. Use "editorSettings.defaultBlock" instead.'); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Http/Controllers/EditorJsImageUploadController.php: -------------------------------------------------------------------------------- 1 | all(), [ 38 | 'image' => 'required|image', 39 | ]); 40 | 41 | if ($validator->fails()) { 42 | return response()->json([ 43 | 'success' => 0, 44 | ]); 45 | } 46 | 47 | $path = $request->file('image')->store( 48 | config('nova-editor-js.toolSettings.image.path'), 49 | config('nova-editor-js.toolSettings.image.disk') 50 | ); 51 | 52 | if (config('nova-editor-js.toolSettings.image.disk') !== 'local') { 53 | $tempPath = $request->file('image')->store( 54 | config('nova-editor-js.toolSettings.image.path'), 55 | 'local' 56 | ); 57 | 58 | $this->applyAlterations(Storage::disk('local')->path($tempPath)); 59 | $thumbnails = $this->applyThumbnails($tempPath); 60 | 61 | $this->deleteThumbnails(Storage::disk('local')->path($tempPath)); 62 | Storage::disk('local')->delete($tempPath); 63 | } else { 64 | $this->applyAlterations(Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->path($path)); 65 | $thumbnails = $this->applyThumbnails($path); 66 | } 67 | 68 | event(new EditorJsImageUploaded(config('nova-editor-js.toolSettings.image.disk'), $path)); 69 | 70 | return response()->json([ 71 | 'success' => 1, 72 | 'file' => [ 73 | 'url' => Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->url($path), 74 | 'thumbnails' => $thumbnails, 75 | ], 76 | ]); 77 | } 78 | 79 | /** 80 | * "Upload" a URL. 81 | */ 82 | public function url(Request $request): JsonResponse 83 | { 84 | $validator = Validator::make($request->all(), [ 85 | 'url' => 'required|url', 86 | ]); 87 | 88 | if ($validator->fails()) { 89 | return response()->json([ 90 | 'success' => 0, 91 | ]); 92 | } 93 | 94 | $url = $request->input('url'); 95 | 96 | // Fetch URL 97 | try { 98 | $response = Http::timeout(5)->get($url)->throw(); 99 | } catch (ConnectionException|RequestException) { 100 | return response()->json([ 101 | 'success' => 0, 102 | ]); 103 | } 104 | 105 | // Validate mime type 106 | $mime = (new finfo)->buffer($response->body(), FILEINFO_MIME_TYPE); 107 | if (! in_array($mime, self::VALID_IMAGE_MIMES, true)) { 108 | return response()->json([ 109 | 'success' => 0, 110 | ]); 111 | } 112 | 113 | $urlBasename = basename(parse_url(url($url), PHP_URL_PATH)); 114 | $nameWithPath = config('nova-editor-js.toolSettings.image.path').'/'.uniqid().$urlBasename; 115 | Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->put($nameWithPath, $response->body()); 116 | event(new EditorJsImageUploaded(config('nova-editor-js.toolSettings.image.disk'), $nameWithPath)); 117 | 118 | return response()->json([ 119 | 'success' => 1, 120 | 'file' => [ 121 | 'url' => Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->url($nameWithPath), 122 | ], 123 | ]); 124 | } 125 | 126 | /** 127 | * @param array $alterations 128 | */ 129 | private function applyAlterations($path, $alterations = []) 130 | { 131 | try { 132 | $image = Image::load($path); 133 | 134 | $imageSettings = config('nova-editor-js.toolSettings.image.alterations'); 135 | 136 | if (! empty($alterations)) { 137 | $imageSettings = $alterations; 138 | } 139 | 140 | if (empty($imageSettings)) { 141 | return; 142 | } 143 | 144 | if (! empty($imageSettings['resize']['width'])) { 145 | $image->width($imageSettings['resize']['width']); 146 | } 147 | 148 | if (! empty($imageSettings['resize']['height'])) { 149 | $image->height($imageSettings['resize']['height']); 150 | } 151 | 152 | if (! empty($imageSettings['optimize'])) { 153 | $image->optimize(); 154 | } 155 | 156 | if (! empty($imageSettings['adjustments']['brightness'])) { 157 | $image->brightness($imageSettings['adjustments']['brightness']); 158 | } 159 | 160 | if (! empty($imageSettings['adjustments']['contrast'])) { 161 | $image->contrast($imageSettings['adjustments']['contrast']); 162 | } 163 | 164 | if (! empty($imageSettings['adjustments']['gamma'])) { 165 | $image->gamma($imageSettings['adjustments']['gamma']); 166 | } 167 | 168 | if (! empty($imageSettings['effects']['blur'])) { 169 | $image->blur($imageSettings['effects']['blur']); 170 | } 171 | 172 | if (! empty($imageSettings['effects']['pixelate'])) { 173 | $image->pixelate($imageSettings['effects']['pixelate']); 174 | } 175 | 176 | if (! empty($imageSettings['effects']['greyscale'])) { 177 | $image->greyscale(); 178 | } 179 | if (! empty($imageSettings['effects']['sepia'])) { 180 | $image->sepia(); 181 | } 182 | 183 | if (! empty($imageSettings['effects']['sharpen'])) { 184 | $image->sharpen($imageSettings['effects']['sharpen']); 185 | } 186 | 187 | $image->save(); 188 | } catch (InvalidManipulation $exception) { 189 | report($exception); 190 | } 191 | } 192 | 193 | /** 194 | * @return array 195 | */ 196 | private function applyThumbnails($path) 197 | { 198 | $thumbnailSettings = config('nova-editor-js.toolSettings.image.thumbnails'); 199 | 200 | $generatedThumbnails = []; 201 | 202 | if (! empty($thumbnailSettings)) { 203 | foreach ($thumbnailSettings as $thumbnailName => $setting) { 204 | $filename = pathinfo($path, PATHINFO_FILENAME); 205 | $extension = pathinfo($path, PATHINFO_EXTENSION); 206 | 207 | $newThumbnailName = $filename.$thumbnailName.'.'.$extension; 208 | $newThumbnailPath = config('nova-editor-js.toolSettings.image.path').'/'.$newThumbnailName; 209 | 210 | Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->copy($path, $newThumbnailPath); 211 | 212 | if (config('nova-editor-js.toolSettings.image.disk') !== 'local') { 213 | Storage::disk('local')->copy($path, $newThumbnailPath); 214 | $newPath = Storage::disk('local')->path($newThumbnailPath); 215 | } else { 216 | $newPath = Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->path($newThumbnailPath); 217 | } 218 | 219 | $this->applyAlterations($newPath, $setting); 220 | 221 | event(new EditorJsThumbnailCreated(config('nova-editor-js.toolSettings.image.disk'), $newThumbnailPath)); 222 | 223 | $generatedThumbnails[] = Storage::disk(config('nova-editor-js.toolSettings.image.disk'))->url($newThumbnailPath); 224 | } 225 | } 226 | 227 | return $generatedThumbnails; 228 | } 229 | 230 | private function deleteThumbnails($path) 231 | { 232 | $thumbnailSettings = config('nova-editor-js.toolSettings.image.thumbnails'); 233 | 234 | if (! empty($thumbnailSettings)) { 235 | foreach ($thumbnailSettings as $thumbnailName => $setting) { 236 | $filename = pathinfo($path, PATHINFO_FILENAME); 237 | $extension = pathinfo($path, PATHINFO_EXTENSION); 238 | 239 | $newThumbnailName = $filename.$thumbnailName.'.'.$extension; 240 | $newThumbnailPath = config('nova-editor-js.toolSettings.image.path').'/'.$newThumbnailName; 241 | 242 | Storage::disk('local')->delete($path, $newThumbnailPath); 243 | } 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/Http/Controllers/EditorJsLinkController.php: -------------------------------------------------------------------------------- 1 | all(), [ 24 | 'url' => 'required|url', 25 | ]); 26 | 27 | if ($validator->fails()) { 28 | return response()->json([ 29 | 'success' => 0, 30 | ]); 31 | } 32 | 33 | // Contents 34 | try { 35 | $url = $request->input('url'); 36 | $response = Http::timeout(5)->get($url)->throw(); 37 | } catch (ConnectionException|RequestException) { 38 | return response()->json([ 39 | 'success' => 0, 40 | ]); 41 | } 42 | 43 | $doc = new DOMDocument; 44 | @$doc->loadHTML((string) $response->getBody()); 45 | $nodes = $doc->getElementsByTagName('title'); 46 | $title = $nodes->item(0)->nodeValue; 47 | $description = ''; 48 | $imageUrl = null; 49 | 50 | $metas = $doc->getElementsByTagName('meta'); 51 | 52 | for ($i = 0; $i < $metas->length; $i++) { 53 | $meta = $metas->item($i); 54 | if ($meta->getAttribute('name') == 'description') { 55 | $description = $meta->getAttribute('content'); 56 | } 57 | 58 | if ($meta->getAttribute('property') == 'og:image') { 59 | $imageUrl = $meta->getAttribute('content'); 60 | } 61 | } 62 | 63 | return response()->json([ 64 | 'success' => 1, 65 | 'meta' => array_filter([ 66 | 'title' => $title ?? $url, 67 | 'description' => $description, 68 | 'imageUrl' => $imageUrl, 69 | ]), 70 | ]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/NovaEditorJs.php: -------------------------------------------------------------------------------- 1 | now(), 26 | 'version' => self::BROKEN_VERSION, 27 | 'blocks' => [ 28 | [ 29 | 'id' => Str::random(10), 30 | 'type' => 'paragraph', 31 | 'data' => [ 32 | 'text' => sprintf( 33 | 'Oh no!
It looks like this component failed to load.
Please contact your system administrator.
Error code: %s', 34 | e($exceptionMessage) 35 | ), 36 | ], 37 | ], 38 | ], 39 | ]); 40 | } 41 | 42 | /** 43 | * Cast the given value. 44 | * 45 | * @param \Illuminate\Database\Eloquent\Model $model 46 | */ 47 | public function get($model, string $key, $value, array $attributes): ?NovaEditorJsData 48 | { 49 | try { 50 | // Recursively decode JSON, to solve a bug where the JSON is double-encoded. 51 | while (is_string($value) && ! empty($value)) { 52 | $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); 53 | } 54 | 55 | // Return null if the new value is null 56 | return $value === null ? null : new NovaEditorJsData($value); 57 | } catch (JsonException $exception) { 58 | return self::getErrorObject($exception->getMessage()); 59 | } 60 | } 61 | 62 | /** 63 | * Prepare the given value for storage. 64 | * 65 | * @param \Illuminate\Database\Eloquent\Model $model 66 | */ 67 | public function set($model, string $key, $value, array $attributes): array 68 | { 69 | if ($value === null) { 70 | return [ 71 | $key => null, 72 | ]; 73 | } 74 | 75 | // Refuse to write if the value is marked as broken 76 | if ($value instanceof NovaEditorJsData && $value->version === self::BROKEN_VERSION) { 77 | return []; 78 | } 79 | 80 | return [ 81 | $key => is_string($value) ? $value : json_encode($value), 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/NovaEditorJsConverter.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected array $renderCallbacks = []; 22 | 23 | public function __construct() 24 | { 25 | $this->registerDefaultCallbacks(); 26 | } 27 | 28 | /** 29 | * Add a custom render callback for the given block. 30 | * 31 | * @param string $block Name of the block, as defined in the JSON 32 | * @param callable $callback Closure that returns a string (or a Stringable) 33 | */ 34 | public function addRender(string $block, callable $callback): void 35 | { 36 | $this->renderCallbacks[$block] = $callback; 37 | } 38 | 39 | /** 40 | * Renders the given EditorJS data to safe HTML. 41 | * 42 | * @return \Illuminate\Support\HtmlString Safe, directly returnable string. 43 | */ 44 | public function generateHtmlOutput(mixed $data): HtmlString 45 | { 46 | if (empty($data) || $data == new stdClass) { 47 | return new HtmlString(''); 48 | } 49 | 50 | // Clean non-string data 51 | if (! is_string($data)) { 52 | try { 53 | $data = json_encode($data, JSON_THROW_ON_ERROR); 54 | } catch (JsonException $exception) { 55 | // noop 56 | } 57 | } 58 | 59 | $config = config('nova-editor-js.validationSettings'); 60 | 61 | try { 62 | // Initialize Editor backend and validate structure 63 | $editor = new EditorJS($data, json_encode($config)); 64 | 65 | // Get sanitized blocks (according to the rules from configuration) 66 | $blocks = $editor->getBlocks(); 67 | 68 | $htmlOutput = ''; 69 | 70 | foreach ($blocks as $block) { 71 | if (array_key_exists($block['type'], $this->renderCallbacks)) { 72 | $htmlOutput .= $this->renderCallbacks[$block['type']]($block); 73 | } 74 | } 75 | 76 | return new HtmlString( 77 | view('nova-editor-js::content', ['content' => $htmlOutput])->render() 78 | ); 79 | } catch (EditorJSException $exception) { 80 | // process exception 81 | return new HtmlString( 82 | "Something went wrong: {$exception->getMessage()}" 83 | ); 84 | } 85 | } 86 | 87 | /** 88 | * Registers all default render helpers 89 | */ 90 | protected function registerDefaultCallbacks(): void 91 | { 92 | $this->addRender( 93 | 'header', 94 | fn ($block) => view('nova-editor-js::heading', $block['data'])->render() 95 | ); 96 | 97 | $this->addRender( 98 | 'paragraph', 99 | fn ($block) => view('nova-editor-js::paragraph', $block['data'])->render() 100 | ); 101 | 102 | $this->addRender( 103 | 'list', 104 | fn ($block) => view('nova-editor-js::list', $block['data'])->render() 105 | ); 106 | 107 | $this->addRender( 108 | 'image', 109 | fn ($block) => view('nova-editor-js::image', array_merge($block['data'], [ 110 | 'classes' => $this->calculateImageClasses($block['data']), 111 | ]))->render() 112 | ); 113 | 114 | $this->addRender( 115 | 'code', 116 | fn ($block) => view('nova-editor-js::code', $block['data'])->render() 117 | ); 118 | 119 | $this->addRender( 120 | 'linkTool', 121 | fn ($block) => view('nova-editor-js::link', $block['data'])->render() 122 | ); 123 | 124 | $this->addRender( 125 | 'checklist', 126 | fn ($block) => view('nova-editor-js::checklist', $block['data'])->render() 127 | ); 128 | 129 | $this->addRender( 130 | 'delimiter', 131 | fn ($block) => view('nova-editor-js::delimiter', $block['data'])->render() 132 | ); 133 | 134 | $this->addRender( 135 | 'table', 136 | fn ($block) => view('nova-editor-js::table', $block['data'])->render() 137 | ); 138 | 139 | $this->addRender( 140 | 'raw', 141 | fn ($block) => view('nova-editor-js::raw', $block['data'])->render() 142 | ); 143 | 144 | $this->addRender( 145 | 'embed', 146 | fn ($block) => view('nova-editor-js::embed', $block['data'])->render() 147 | ); 148 | } 149 | 150 | /** 151 | * @return string 152 | */ 153 | protected function calculateImageClasses($blockData) 154 | { 155 | $classes = []; 156 | foreach ($blockData as $key => $data) { 157 | if (is_bool($data) && $data === true) { 158 | $classes[] = $key; 159 | } 160 | } 161 | 162 | return implode(' ', $classes); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/NovaEditorJsData.php: -------------------------------------------------------------------------------- 1 | $attributes 18 | * @return void 19 | */ 20 | public function __construct($attributes = []) 21 | { 22 | if (is_string($attributes)) { 23 | $attributes = json_decode($attributes); 24 | } 25 | 26 | if (! is_iterable($attributes)) { 27 | $attributes = Arr::wrap($attributes); 28 | } 29 | 30 | foreach ($attributes as $key => $value) { 31 | $this->attributes[$key] = $value; 32 | } 33 | } 34 | 35 | /** 36 | * @return \Illuminate\Support\HtmlString 37 | */ 38 | public function toHtml() 39 | { 40 | return NovaEditorJs::generateHtmlOutput($this); 41 | } 42 | 43 | /** 44 | * @return string 45 | */ 46 | public function __toString() 47 | { 48 | return (string) $this->toHtml(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/NovaEditorJsField.php: -------------------------------------------------------------------------------- 1 | withMeta([ 27 | 'editorSettings' => [ 28 | 'placeholder' => config('nova-editor-js.editorSettings.placeholder', ''), 29 | 'defaultBlock' => $defaultBlock, 30 | 'autofocus' => config('nova-editor-js.editorSettings.autofocus', false), 31 | 'rtl' => config('nova-editor-js.editorSettings.rtl', false), 32 | ], 33 | 'toolSettings' => config('nova-editor-js.toolSettings'), 34 | 'uploadImageByFileEndpoint' => route('editor-js-upload-image-by-file'), 35 | 'uploadImageByUrlEndpoint' => route('editor-js-upload-image-by-url'), 36 | 'fetchUrlEndpoint' => route('editor-js-fetch-url'), 37 | ]); 38 | } 39 | 40 | /** 41 | * Resolve the field's value for display. 42 | * 43 | * 44 | * @throws \Throwable 45 | */ 46 | public function resolveForDisplay($resource, ?string $attribute = null): void 47 | { 48 | $attribute = $attribute ?? $this->attribute; 49 | if ($attribute === 'ComputedField') { 50 | return; 51 | } 52 | 53 | $value = data_get($resource, str_replace('->', '.', $attribute), $placeholder = new \stdClass); 54 | 55 | if (is_callable($this->resolveCallback)) { 56 | $value = call_user_func($this->resolveCallback, $value, $resource, $attribute); 57 | } 58 | 59 | if (! $this->displayCallback) { 60 | $this->withMeta(['asHtml' => true]); 61 | $this->value = (string) NovaEditorJs::generateHtmlOutput($value); 62 | 63 | return; 64 | } 65 | 66 | if (! is_callable($this->displayCallback) || $value === $placeholder) { 67 | return; 68 | } 69 | 70 | // Convert from JSON 71 | if (is_string($value)) { 72 | try { 73 | $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); 74 | } catch (JsonException) { 75 | // 76 | } 77 | } 78 | 79 | if ($value !== null) { 80 | $value = new NovaEditorJsData($value); 81 | } 82 | 83 | $this->value = call_user_func($this->displayCallback, $value); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/config/nova-editor-js.php: -------------------------------------------------------------------------------- 1 | [ 10 | 'placeholder' => '', 11 | 'defaultBlock' => 'paragraph', 12 | 'autofocus' => false, 13 | 'rtl' => false, 14 | ], 15 | 16 | /** 17 | * Configure tools 18 | */ 19 | 'toolSettings' => [ 20 | 'header' => [ 21 | 'activated' => true, 22 | 'placeholder' => 'Heading', 23 | 'shortcut' => 'CMD+SHIFT+H', 24 | ], 25 | 'list' => [ 26 | 'activated' => true, 27 | 'inlineToolbar' => true, 28 | 'shortcut' => 'CMD+SHIFT+L', 29 | ], 30 | 'code' => [ 31 | 'activated' => true, 32 | 'placeholder' => '', 33 | 'shortcut' => 'CMD+SHIFT+C', 34 | ], 35 | 'link' => [ 36 | 'activated' => true, 37 | 'shortcut' => 'CMD+SHIFT+L', 38 | ], 39 | 'image' => [ 40 | 'activated' => true, 41 | 'shortcut' => 'CMD+SHIFT+I', 42 | 'path' => 'public/images', 43 | 'disk' => 'local', 44 | 'alterations' => [ 45 | 'resize' => [ 46 | 'width' => false, // integer 47 | 'height' => false, // integer 48 | ], 49 | 'optimize' => true, // true or false 50 | 'adjustments' => [ 51 | 'brightness' => false, // -100 to 100 52 | 'contrast' => false, // -100 to 100 53 | 'gamma' => false, // 0.1 to 9.99 54 | ], 55 | 'effects' => [ 56 | 'blur' => false, // 0 to 100 57 | 'pixelate' => false, // 0 to 100 58 | 'greyscale' => false, // true or false 59 | 'sepia' => false, // true or false 60 | 'sharpen' => false, // 0 to 100 61 | ], 62 | ], 63 | 'thumbnails' => [ 64 | // Specify as many thumbnails as required. Key is used as the name. 65 | '_small' => [ 66 | 'resize' => [ 67 | 'width' => 250, // integer 68 | 'height' => 250, // integer 69 | ], 70 | 'optimize' => true, // true or false 71 | 'adjustments' => [ 72 | 'brightness' => false, // -100 to 100 73 | 'contrast' => false, // -100 to 100 74 | 'gamma' => false, // 0.1 to 9.99 75 | ], 76 | 'effects' => [ 77 | 'blur' => false, // 0 to 100 78 | 'pixelate' => false, // 0 to 100 79 | 'greyscale' => false, // true or false 80 | 'sepia' => false, // true or false 81 | 'sharpen' => false, // 0 to 100 82 | ], 83 | ], 84 | ], 85 | ], 86 | 'inlineCode' => [ 87 | 'activated' => true, 88 | 'shortcut' => 'CMD+SHIFT+A', 89 | ], 90 | 'checklist' => [ 91 | 'activated' => true, 92 | 'inlineToolbar' => true, 93 | 'shortcut' => 'CMD+SHIFT+J', 94 | ], 95 | 'marker' => [ 96 | 'activated' => true, 97 | 'shortcut' => 'CMD+SHIFT+M', 98 | ], 99 | 'delimiter' => [ 100 | 'activated' => true, 101 | ], 102 | 'table' => [ 103 | 'activated' => true, 104 | 'inlineToolbar' => true, 105 | ], 106 | 'raw' => [ 107 | 'activated' => true, 108 | 'placeholder' => '', 109 | ], 110 | 'embed' => [ 111 | 'activated' => true, 112 | 'inlineToolbar' => true, 113 | 'services' => [ 114 | 'codepen' => true, 115 | 'imgur' => false, 116 | 'vimeo' => true, 117 | 'youtube' => true, 118 | ], 119 | ], 120 | ], 121 | 122 | /** 123 | * Output validation config 124 | * https://github.com/editor-js/editorjs-php 125 | */ 126 | 'validationSettings' => [ 127 | 'tools' => [ 128 | 'header' => [ 129 | 'text' => [ 130 | 'type' => 'string', 131 | ], 132 | 'level' => [ 133 | 'type' => 'int', 134 | 'canBeOnly' => [1, 2, 3, 4, 5], 135 | ], 136 | ], 137 | 'paragraph' => [ 138 | 'text' => [ 139 | 'type' => 'string', 140 | 'allowedTags' => 'i,b,u,a[href],span[class],code[class],mark[class]', 141 | ], 142 | ], 143 | 'list' => [ 144 | 'style' => [ 145 | 'type' => 'string', 146 | 'canBeOnly' => [ 147 | 0 => 'ordered', 148 | 1 => 'unordered', 149 | ], 150 | ], 151 | 'items' => [ 152 | 'type' => 'array', 153 | 'data' => [ 154 | '-' => [ 155 | 'type' => 'string', 156 | 'allowedTags' => 'i,b,u', 157 | ], 158 | ], 159 | ], 160 | ], 161 | 'image' => [ 162 | 'file' => [ 163 | 'type' => 'array', 164 | 'data' => [ 165 | 'url' => [ 166 | 'type' => 'string', 167 | ], 168 | 'thumbnails' => [ 169 | 'type' => 'array', 170 | 'required' => false, 171 | 'data' => [ 172 | '-' => [ 173 | 'type' => 'string', 174 | ], 175 | ], 176 | ], 177 | ], 178 | ], 179 | 'caption' => [ 180 | 'type' => 'string', 181 | ], 182 | 'withBorder' => [ 183 | 'type' => 'boolean', 184 | ], 185 | 'withBackground' => [ 186 | 'type' => 'boolean', 187 | ], 188 | 'stretched' => [ 189 | 'type' => 'boolean', 190 | ], 191 | ], 192 | 'code' => [ 193 | 'code' => [ 194 | 'type' => 'string', 195 | ], 196 | ], 197 | 'linkTool' => [ 198 | 'link' => [ 199 | 'type' => 'string', 200 | ], 201 | 'meta' => [ 202 | 'type' => 'array', 203 | 'data' => [ 204 | 'title' => [ 205 | 'type' => 'string', 206 | ], 207 | 'description' => [ 208 | 'type' => 'string', 209 | ], 210 | 'image' => [ 211 | 'type' => 'array', 212 | 'required' => false, 213 | 'data' => [ 214 | 'url' => [ 215 | 'type' => 'string', 216 | ], 217 | ], 218 | ], 219 | ], 220 | ], 221 | ], 222 | 'checklist' => [ 223 | 'items' => [ 224 | 'type' => 'array', 225 | 'data' => [ 226 | '-' => [ 227 | 'type' => 'array', 228 | 'data' => [ 229 | 'text' => [ 230 | 'type' => 'string', 231 | 'required' => false, 232 | ], 233 | 'checked' => [ 234 | 'type' => 'boolean', 235 | 'required' => false, 236 | ], 237 | ], 238 | 239 | ], 240 | ], 241 | ], 242 | ], 243 | 'delimiter' => [ 244 | 245 | ], 246 | 'table' => [ 247 | 'withHeadings' => [ 248 | 'type' => 'boolean', 249 | 'required' => false, 250 | ], 251 | 'content' => [ 252 | 'type' => 'array', 253 | 'data' => [ 254 | '-' => [ 255 | 'type' => 'array', 256 | 'data' => [ 257 | '-' => [ 258 | 'type' => 'string', 259 | 'allowedTags' => 'i,b,u,a[href],span[class],code[class],mark[class]', 260 | ], 261 | ], 262 | ], 263 | ], 264 | ], 265 | ], 266 | 'raw' => [ 267 | 'html' => [ 268 | 'type' => 'string', 269 | 'allowedTags' => '*', 270 | ], 271 | ], 272 | 'embed' => [ 273 | 'service' => [ 274 | 'type' => 'string', 275 | ], 276 | 'source' => [ 277 | 'type' => 'string', 278 | ], 279 | 'embed' => [ 280 | 'type' => 'string', 281 | ], 282 | 'width' => [ 283 | 'type' => 'int', 284 | ], 285 | 'height' => [ 286 | 'type' => 'int', 287 | ], 288 | 'caption' => [ 289 | 'type' => 'string', 290 | 'required' => false, 291 | ], 292 | ], 293 | ], 294 | ], 295 | ]; 296 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/EditorJsImageUploadControllerTest.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function () { 30 | Route::post('/test/image/file', [EditorJsImageUploadController::class, 'file']); 31 | Route::post('/test/image/url', [EditorJsImageUploadController::class, 'url']); 32 | }); 33 | } 34 | 35 | /** 36 | * Test an image upload. 37 | * 38 | * @param string $path Path to the image file 39 | * 40 | * @dataProvider provideValidFilesForImageUpload 41 | */ 42 | public function testImageUpload(string $path): void 43 | { 44 | Storage::fake(); 45 | Storage::fake('public'); 46 | $fake = Event::fake(); 47 | DB::setEventDispatcher($fake); 48 | 49 | $uploadedFile = UploadedFile::fake()->create('file', 1024, (new finfo)->file($path, FILEINFO_MIME_TYPE)); 50 | if ($fp = $uploadedFile->openFile('w')) { 51 | $fp->fwrite(file_get_contents($path)); 52 | } 53 | 54 | $response = $this->post('/test/image/file', [ 55 | 'image' => $uploadedFile, 56 | ])->assertOk()->assertJson(['success' => 1]); 57 | 58 | $responseUrl = $response->json('file.url'); 59 | $this->assertNotEmpty($responseUrl, 'Response file URL is empty'); 60 | 61 | $storageBaseUrl = Storage::disk('public')->url(''); 62 | $this->assertStringStartsWith($storageBaseUrl, $responseUrl, 'Response URL seems to not be in a public folder'); 63 | 64 | $createdFiles = Storage::disk()->allFiles(); 65 | $this->assertCount(2, $createdFiles, 'Storage seems to not contain exactly two files (one upload, one saved)'); 66 | 67 | $filesThatLookLikeTheUpload = array_filter( 68 | $createdFiles, 69 | fn ($file) => Str::endsWith($file, basename($responseUrl)), 70 | ); 71 | 72 | Event::assertDispatched(EditorJsImageUploaded::class); 73 | Event::assertDispatched(EditorJsThumbnailCreated::class); 74 | $this->assertCount(1, $filesThatLookLikeTheUpload, 'Storage doesn\'t seem to contain a file with the same name as the returned URL'); 75 | } 76 | 77 | /** 78 | * Test uploading a non-image. 79 | */ 80 | public function testNonImageUpload(): void 81 | { 82 | Storage::fake(); 83 | Storage::fake('public'); 84 | 85 | $uploadedFile = UploadedFile::fake()->createWithContent('upload', 'Hello World!'); 86 | 87 | $response = $this->post('/test/image/file', [ 88 | 'image' => $uploadedFile, 89 | ])->assertOk()->assertJson(['success' => 0]); 90 | } 91 | 92 | /** 93 | * Test submitting an image URL causes the file to be stored to disk and returned. 94 | * 95 | * @param string $file path to the file returned by the URL 96 | * 97 | * @dataProvider provideValidFiles 98 | */ 99 | public function testValidImageUrlSubmission(string $file): void 100 | { 101 | Storage::fake(); 102 | Storage::fake('public'); 103 | $fake = Event::fake(); 104 | DB::setEventDispatcher($fake); 105 | 106 | Http::fake([ 107 | 'https://example.com/image.bin' => Http::response(file_get_contents($file)), 108 | ])->preventStrayRequests(); 109 | 110 | $response = $this->post('/test/image/url', [ 111 | 'url' => 'https://example.com/image.bin', 112 | ])->assertOk()->assertJson(['success' => 1]); 113 | 114 | $responseUrl = $response->json('file.url'); 115 | $this->assertNotEmpty($responseUrl, 'Response file URL is empty'); 116 | 117 | $storageBaseUrl = Storage::disk('public')->url(''); 118 | $this->assertStringStartsWith($storageBaseUrl, $responseUrl, 'Response URL seems to not be in a public folder'); 119 | 120 | $createdFiles = Storage::disk()->allFiles(); 121 | $this->assertCount(1, $createdFiles, 'Storage seems to not contain exactly one file'); 122 | 123 | Event::assertDispatched(EditorJsImageUploaded::class); 124 | $this->assertEquals(basename($createdFiles[0]), basename($responseUrl), 'Response URL filename doesn\'t match created file basename'); 125 | } 126 | 127 | /** 128 | * Test submitting a non-image URL causes the request to fail. 129 | */ 130 | public function testInvalidImageUrlSubmission(): void 131 | { 132 | Http::fake([ 133 | 'https://example.com/image.bin' => Http::response('Hello World!'), 134 | ])->preventStrayRequests(); 135 | 136 | $this->post('/test/image/url', [ 137 | 'url' => 'https://example.com/image.bin', 138 | ])->assertOk()->assertJson(['success' => 0]); 139 | } 140 | 141 | /** 142 | * Test submitting a URL that's not valid, but is a properly formed HTTP 143 | * URL, still sends out a ping (but fails, eventually). 144 | */ 145 | public function testSubmittingADeadUrl(): void 146 | { 147 | Http::fake([ 148 | 'https://example.invalid/image.bin' => Http::response('Hello World!'), 149 | ])->preventStrayRequests(); 150 | 151 | $this->post('/test/image/url', [ 152 | 'url' => 'https://example.invalid/image.bin', 153 | ])->assertOk()->assertJson(['success' => 0]); 154 | 155 | Http::assertSentCount(1); 156 | } 157 | 158 | /** 159 | * Test submitting a URL which the server won't or cannot provide returns an error. 160 | * Also implicitly handles timeouts, since that's the same block. 161 | */ 162 | public function testSubmittingImageUrlWithErrors(): void 163 | { 164 | Http::fake([ 165 | 'https://example.com/client/image.bin' => Http::response(test_resource('responses/image.png'), Response::HTTP_BAD_GATEWAY), 166 | 'https://example.com/server/image.bin' => Http::response(test_resource('responses/image.png'), Response::HTTP_GONE), 167 | ])->preventStrayRequests(); 168 | 169 | $this->post('/test/image/url', [ 170 | 'url' => 'https://example.com/client/image.bin', 171 | ])->assertOk()->assertJson(['success' => 0]); 172 | 173 | $this->post('/test/image/url', [ 174 | 'url' => 'https://example.com/server/image.bin', 175 | ])->assertOk()->assertJson(['success' => 0]); 176 | 177 | Http::assertSentCount(2); 178 | } 179 | 180 | /** 181 | * Provides a list of valid image files to test. 182 | * 183 | * @return string[][] 184 | */ 185 | public static function provideValidFiles(): array 186 | { 187 | return [ 188 | 'gif' => [test_resource('responses/image.gif')], 189 | 'jpg' => [test_resource('responses/image.jpg')], 190 | 'png' => [test_resource('responses/image.png')], 191 | 'svg' => [test_resource('responses/image.svg')], 192 | 'svg' => [test_resource('responses/image.svg')], 193 | ]; 194 | } 195 | 196 | /** 197 | * Provides a subset of the available image formats, since svg isn't supported by the GD library. 198 | * 199 | * @return string[][] 200 | */ 201 | public static function provideValidFilesForImageUpload(): array 202 | { 203 | return Arr::except(self::provideValidFiles(), [ 204 | 'svg', 205 | ]); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/EditorJsLinkControllerTest.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function () { 20 | Route::post('/test/url', [EditorJsLinkController::class, 'fetch']); 21 | }); 22 | } 23 | 24 | /** 25 | * Checks simple URL fetch. 26 | */ 27 | public function testFetchValidUrl(): void 28 | { 29 | Http::fake([ 30 | 'https://example.com' => Http::response(file_get_contents(test_resource('responses/simple.html'))), 31 | ])->preventStrayRequests(); 32 | 33 | $this->post('/test/url', [ 34 | 'url' => 'https://example.com', 35 | ])->assertOk()->assertJson([ 36 | 'success' => 1, 37 | 'meta' => [ 38 | 'title' => 'Example Domain', 39 | 'description' => 'This is a description', 40 | ], 41 | ]); 42 | } 43 | 44 | /** 45 | * Checks simple URL fetch. 46 | */ 47 | public function testImageDetermination(): void 48 | { 49 | Http::fake([ 50 | 'https://example.com' => Http::response(file_get_contents(test_resource('responses/with-image.html'))), 51 | ])->preventStrayRequests(); 52 | 53 | $this->post('/test/url', [ 54 | 'url' => 'https://example.com', 55 | ])->assertOk()->assertJson([ 56 | 'success' => 1, 57 | 'meta' => [ 58 | 'title' => 'Example Domain with an image', 59 | 'description' => 'This is a description', 60 | 'imageUrl' => 'https://example.com/image.jpg', 61 | ], 62 | ]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Feature/Views/LinkViewTest.php: -------------------------------------------------------------------------------- 1 | view('nova-editor-js::link', [ 19 | 'link' => 'https://example.com', 20 | 'meta' => [ 21 | 'title' => 'Example Domain', 22 | 'description' => 'This is a description', 23 | ], 24 | ])->assertSeeText('Example Domain') 25 | ->assertSeeText('This is a description') 26 | ->assertDontSee('view('nova-editor-js::link', [ 35 | 'link' => 'https://example.com', 36 | 'meta' => [ 37 | 'title' => 'Example Domain', 38 | 'description' => 'This is a description', 39 | 'imageUrl' => 'https://example.com/image.jpg', 40 | ], 41 | ])->assertSeeText('Example Domain') 42 | ->assertSeeText('This is a description') 43 | ->assertSee('url(\'https://example.com/image.jpg\')', false); 44 | } 45 | 46 | /** 47 | * Checks an old-form image render. 48 | */ 49 | public function testWithImageInOldFormat(): void 50 | { 51 | $this->view('nova-editor-js::link', [ 52 | 'link' => 'https://example.com', 53 | 'meta' => [ 54 | 'title' => 'Example Domain', 55 | 'description' => 'This is a description', 56 | 'image' => [ 57 | 'url' => 'https://example.com/image.jpg', 58 | 'caption' => 'This is a caption', 59 | ], 60 | ], 61 | ])->assertSeeText('Example Domain') 62 | ->assertSeeText('This is a description') 63 | ->assertSee('url(\'https://example.com/image.jpg\')', false) 64 | ->assertDontSee('This is a caption'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Feature/Views/ViewTestHelpers.php: -------------------------------------------------------------------------------- 1 | NovaEditorJsCast::class, 24 | ]; 25 | 26 | protected $fillable = [ 27 | 'data', 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /tests/Fixtures/TestServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->useDatabasePath(__DIR__.'/database/'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/2022_07_17_153928_create_dummies_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 20 | $table->json('data')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::drop('dummies'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Fixtures/nova/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/nova", 3 | "version": "4.99.0", 4 | "require": { 5 | "php": ">=7.1.0", 6 | "illuminate/support": ">=5.7.15" 7 | }, 8 | "autoload": { 9 | "psr-4": { 10 | "Laravel\\Nova\\": "src/" 11 | }, 12 | "files": [ 13 | "./src/aliases.php" 14 | ] 15 | }, 16 | "config": { 17 | "sort-packages": true 18 | }, 19 | "extra": { 20 | "laravel": { 21 | "providers": [ 22 | "Laravel\\Nova\\NovaCoreServiceProvider" 23 | ], 24 | "aliases": { 25 | "Nova": "Laravel\\Nova\\Nova" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Fixtures/nova/src/Events/ServingNova.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Animi eum nesciunt cupiditate. 5 |

6 |
7 |
8 |

9 | Est nihil repellendus delectus rem. 10 |

11 |
12 |
13 |

14 | Labore voluptatem non omnis aliquam dolore hic dolorum. 15 |

16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/Fixtures/resources/json/editorjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "time": 1570309537000, 3 | "blocks": [ 4 | { 5 | "type": "header", 6 | "data": { 7 | "text": "Animi eum nesciunt cupiditate.", 8 | "level": 4 9 | } 10 | }, 11 | { 12 | "type": "paragraph", 13 | "data": { 14 | "text": "Est nihil repellendus delectus rem." 15 | } 16 | }, 17 | { 18 | "type": "paragraph", 19 | "data": { 20 | "text": "Labore voluptatem non omnis aliquam dolore hic dolorum." 21 | } 22 | } 23 | ], 24 | "version": "2.15.0" 25 | } 26 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function () { 31 | \Illuminate\Http\Client\PendingRequest::macro('preventStrayRequests', function () { 32 | return $this; 33 | }); 34 | }); 35 | } 36 | 37 | /** 38 | * Path to config file from here 39 | */ 40 | private const CONFIG_PATH = __DIR__.'/../src/config/nova-editor-js.php'; 41 | 42 | /** 43 | * Get package providers. 44 | * 45 | * @param \Illuminate\Foundation\Application $app 46 | * @return array 47 | */ 48 | protected function getPackageProviders($app) 49 | { 50 | return [ 51 | FieldServiceProvider::class, 52 | TestServiceProvider::class, 53 | ]; 54 | } 55 | 56 | /** 57 | * Define environment setup. 58 | * 59 | * @param \Illuminate\Foundation\Application $app 60 | * @return void 61 | */ 62 | protected function getEnvironmentSetUp($app) 63 | { 64 | $app['config']->set('nova-editor-js', require self::CONFIG_PATH); 65 | $app['config']->set('database.default', 'testing'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Unit/JsonContentTest.php: -------------------------------------------------------------------------------- 1 | file_get_contents(self::TEST_FILE_JSON), 28 | 'html' => file_get_contents(self::TEST_FILE_HTML), 29 | ]; 30 | } 31 | 32 | /** 33 | * A basic test example. 34 | */ 35 | public function testStringValue(): void 36 | { 37 | // Get contents 38 | $contents = $this->getFileContents(); 39 | 40 | // Verify JSON 41 | $json = $contents['json']; 42 | $this->assertIsString($json); 43 | 44 | // Convert to HTML 45 | $html = NovaEditorJs::generateHtmlOutput($json); 46 | 47 | // Ensure identicality 48 | $this->assertEquals($contents['html'], $html); 49 | } 50 | 51 | /** 52 | * A basic test example. 53 | */ 54 | public function testJsonValue(): void 55 | { 56 | // Get contents 57 | $contents = $this->getFileContents(); 58 | 59 | // Verify JSON 60 | $json = $contents['json']; 61 | $this->assertIsString($json); 62 | 63 | // Convert to array (just like Laravel would do) 64 | $json = json_decode($json, true); 65 | $this->assertIsArray($json); 66 | 67 | // Convert to HTML 68 | $html = NovaEditorJs::generateHtmlOutput($json); 69 | 70 | // Ensure identicality 71 | $this->assertEquals($contents['html'], $html); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Unit/NovaEditorJsCastTest.php: -------------------------------------------------------------------------------- 1 | 1658064476, 20 | 'blocks' => [ 21 | [ 22 | 'type' => 'paragraph', 23 | 'data' => [ 24 | 'text' => 'Paragraph', 25 | ], 26 | ], 27 | ], 28 | 'version' => '2.3.0', 29 | ]; 30 | 31 | /** 32 | * Test a very basic save-and-decode. 33 | */ 34 | public function testSavingAndDecoding(): void 35 | { 36 | Dummy::create(['data' => self::VALID_BLOCK]); 37 | 38 | $instance = Dummy::first(); 39 | 40 | $this->assertInstanceOf(NovaEditorJsData::class, $instance->data); 41 | $this->assertEquals(self::VALID_BLOCK, $instance->data->getAttributes()); 42 | } 43 | 44 | /** 45 | * Test saving and decoding a value that's already JSON-encoded. 46 | */ 47 | public function testSavingPreCompiledJson(): void 48 | { 49 | Dummy::create(['data' => json_encode(self::VALID_BLOCK)]); 50 | 51 | $instance = Dummy::first(); 52 | 53 | $this->assertInstanceOf(NovaEditorJsData::class, $instance->data); 54 | $this->assertEquals(self::VALID_BLOCK, $instance->data->getAttributes()); 55 | } 56 | 57 | /** 58 | * Test decoding a value that's double-JSON-encoded, basically bug mitigation. 59 | */ 60 | public function testReadingDoubleEncodedJson(): void 61 | { 62 | DB::statement('INSERT INTO `dummies` (`data`) VALUES (?)', [json_encode(json_encode(self::VALID_BLOCK))]); 63 | 64 | $instance = Dummy::first(); 65 | 66 | $this->assertInstanceOf(NovaEditorJsData::class, $instance->data); 67 | $this->assertEquals(self::VALID_BLOCK, $instance->data->getAttributes()); 68 | } 69 | 70 | /** 71 | * Test reading null values in JSON. 72 | */ 73 | public function testReadingNullValues(): void 74 | { 75 | DB::statement('INSERT INTO `dummies` (`data`) VALUES (?), (null)', [json_encode(null)]); 76 | 77 | [$jsonInstance, $nullInstance] = Dummy::get(); 78 | 79 | $this->assertNull($jsonInstance->data); 80 | $this->assertNull($nullInstance->data); 81 | } 82 | 83 | /** 84 | * Test writing null values, a json-encoded null value 85 | * should be stored as-is. 86 | */ 87 | public function testReadingNullValue(): void 88 | { 89 | Dummy::create(['data' => null]); 90 | Dummy::create(['data' => json_encode(null)]); 91 | 92 | $rows = DB::select('SELECT `id`, `data` FROM `dummies`'); 93 | 94 | $this->assertNull($rows[0]->data); 95 | $this->assertSame('null', $rows[1]->data); 96 | } 97 | 98 | /** 99 | * Finally, check if reading broken JSON is handled properly. 100 | */ 101 | public function testReadingInvalidJson(): void 102 | { 103 | DB::statement('INSERT INTO `dummies` (`data`) VALUES (?)', ['{"}']); 104 | 105 | $instance = Dummy::first(); 106 | 107 | $this->assertInstanceOf(NovaEditorJsData::class, $instance->data); 108 | $this->assertNotNull($instance->data->version, 'Expected version key on data'); 109 | $this->assertEquals(NovaEditorJsCast::BROKEN_VERSION, $instance->data->version, 'Expected version to match "broken" version'); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/helpers.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /tests/resources/responses/image.txt: -------------------------------------------------------------------------------- 1 | I am an image! 2 | -------------------------------------------------------------------------------- /tests/resources/responses/image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/advoor/nova-editor-js/b4230abd961be24bc026852b275adafebb6c989f/tests/resources/responses/image.webp -------------------------------------------------------------------------------- /tests/resources/responses/simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Example Domain 8 | 9 | 10 | 11 |

Hello World

12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/resources/responses/with-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Example Domain with an image 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

Hello World

18 | 19 | 20 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const mix = require('laravel-mix'); 4 | 5 | require('./nova.mix'); 6 | 7 | mix.setPublicPath('dist') 8 | .js('resources/js/index.js', 'js/field.js') 9 | .vue({ version: 3 }) 10 | .css('resources/css/field.css', 'css/field.css') 11 | .nova('advoor/nova-editor-js'); 12 | --------------------------------------------------------------------------------