Editor Wysiwyg em blocos com Editor.js
O Editor.js é um editor de texto rico (wysiwyg) que entre suas funcionalidades-chave estão: ser um editor por blocos(como o wordpress, se você já utilizou), retornar o texto editado em formato JSON e não marcações HTML e ser extensível com um API simples.
Muitos editores wysiwyg utilizam um elemento HTML com a propriedade contenteditable ou um iframe e alteram o conteúdo visível que será o mesmo que será “salvo”. O Editor.js divide o conteúdo em blocos como parágrafos, listas, imagens, títulos, citações, etc., salvando este conteúdo em um formato JSON mais limpo, que pode ser processado de diversas formas no backend, podendo assim gerar diversos tipos de marcações além de HTML, como, por exemplo, markdown. Veja um exemplo de saída JSON do editor com um título de seção nível dois, um parágrafo e outro título de seção nível três.
{
"time": 1550476186479,
"blocks": [
{
"type": "header",
"data": {
"text": "Lorem Ipsum",
"level": 2
}
},
{
"type": "paragraph",
"data": {
"text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris porta erat posuere, luctus odio vitae, rhoncus neque. Etiam congue dolor et varius consectetur. Nam vel suscipit magna."
}
},
{
"type": "header",
"data": {
"text": "Aenean faucibus eget",
"level": 3
}
},
}
Começando com o Editor.js
Primeiro instalamos o editor utilizando o npm:
npm i @editorjs/editorjs
Então criamos um arquivo javascript(index.js
) na pasta js do projeto, incluímos o módulo em nossa aplicação e iniciamos o editor.
import EditorJS from '@editorjs/editorjs';
const editor = new EditorJS({
holder: 'editorjs'
});
Instanciamos o EditorJS
passando um objeto de configuração onde temos a propriedade holder que é o id do elemento HTML da nossa página que irá conter o editor.
Com o básico do editor configurado, instalamos o webpack e o processador de css.
npm i webpack-cli css-loader style-loader
Criamos a configuração do webpack.
// arquivo webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
entry: './js/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js',
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader',"css-loader",],
},
],
},
};
E geramos o bundle através do comando:
node_modules/.bin/webpack
O arquivo ficará em dist/index.js
e devemos incluir ele na nossa página com o elemento de id editorjs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor.js</title>
<style>
body{
background: #CCC;
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
gap:10px
}
#editorjs{
background: #fff;
width:750px;
}
</style>
</head>
<body>
<div id="editorjs"></div>
<script src="dist/index.js"></script>
</body>
</html>
Por padrão o Editor.js vem apenas com o bloco de parágrafo, mas pode ser facilmente estendido, instalando diversas extensões/plugins, sendo cada uma delas é um arquivo separado com sua própria lógica. Por exemplo, temos os seguintes blocos:
- Header: Titulo de seção
- Image: Imagem(necessita de um serviço de backend).
- Table: Bloco de tabela..
- List: Lista de itens.
- Code: Códigos.
- e outros…
Para instalar as extensões é bastante fácil. Vamos adicionar o bloco Header
como exemplo. Primeiro instalamos via npm
:
npm i @editorjs/header
Então importamos o módulo no nosso arquivo index.js e adicionamos na configuração do editor.
import Header from '@editorjs/header';
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: {
class: Header
}
}
});
Cada chave da propriedade de tools
será um tipo bloco, e o nome utilizado para ele será o type do bloco no JSON gerado. O principal atributo o objeto é o class que é a classe da extensão que estamos adicionamos, outros blocos podem ter mais configurações, explicadas em sua documentação.
* Obs.: após alterar a configuração do editor não se esqueça de gerar o bundle novamente utilizando o comando do webpack mostrado acima.
Instalar outros blocos segue a mesma linha:
npm i @editorjs/list
npm i @editorjs/code
npm i @editorjs/image
Configurando o editor…
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
import CodeTool from '@editorjs/code';
import ImageTool from '@editorjs/image';
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: {
class: Header
},
list:{
class: List
},
code: {
class: CodeTool
},
image: {
class: ImageTool,
config: {
endpoints: {
byFile: 'http://localhost:8000/upload-file.php'
}
}
}
}
});
Como já comentado, algumas extensões podem necessitar de algumas configurações, como a Image, que necessita do endpoint para onde ela irá realizar o post com a imagem para ser armazenada(veja aqui um exemplo simples de endpoint para salvar a imagem). A extensão Image possui diversas configuração que podem ser vista na documentação dela.
Algumas extensões não são novos blocos, mas sim inline, elas incluem novos recursos em blocos já existentes, por exemplo, temos a extensão Underline que adiciona o estilo sublinhado aos blocos.
Alguns blocos não exibem a inlineToolbar(a barra de opções de negrito, itálico, link, etc) por padrão(como o Header) então devemos habilitá-la nas configurações.
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: {
class: Header,
inlineToolbar: true
},
underline: {
class: Underline
},
// ...
}
});
Salvando os Dados
Para salvar os dados do editor basta chamar o método save
da instância dele, que retorna um promise com o objeto json com os dados. Veja um exemplo do processo de salvar clicando em um botão e salvando o json em um textarea de id output
const saveButton = document.querySelector('#btn-save');
const output = document.querySelector('#output');
saveButton.addEventListener('click', function(){
editor.save().then( (out) =>{
output.value = JSON.stringify(out);
}).catch((error) => {
console.log('Falha: ', error)
});
});
Veja o exemplo do editor.
Caso seja necessário sincronizar o conteúdo do editor com outro componente podemos utilizar o callback onChange
que recebe uma função que será chamada toda vez que o conteúdo for alterado. Ela recebe um objeto api, e um objeto de evento. No objeto de api podemos chamar o método save
através da propriedade saver
.
const editor = new EditorJS({
holder: 'editorjs',
onChange: function(api, event){
api.saver.save().then((out) => {
console.log(out);
})
},
tools: {
//...
}
});
Se for necessário carregar um conteúdo no editor na sua inicialização, podemos fazer isso passando um objeto para a propriedade de configuração data
:
const editor = new EditorJS({
holder: 'editorjs',
data:{"time":1662486052475,"blocks":[{"id":"0q8M-0fdEa","type":"paragraph","data":{"text":"um teste"}}],"version":"2.25.0"},
tools: {
//..
}
});
Criando um bloco personalizado
Um dos recursos mais interessantes do Editor.js é que ele permite criar extensões para uma necessidade específica, ou seja, ele permite criar nossos blocos de forma relativamente fácil.
Vamos então criar um bloco de citação(blockquote
) como exemplo. Para isso vamos criar uma pasta com o nome blockquote e dentro dela criamos dois arquivos, um index.js
que irá conter a lógica do bloco e index.css
onde colocaremos algumas classes necessárias.
Um bloco necessita de pelo menos três métodos e o construtor, sendo eles:
toolbox
: onde definimos o ícone de seleção do bloco, ou seja, o que aparece quando clicamos no + para criar o bloco.- render: este método é responsável por gerar a visualização do bloco no editor, nele criamos os elementos HTML, adicionamos elementos e eventos necessários para nosso bloco. Devemos retornar um elemento HTML.
- save: método chamado quando o conteúdo do editor está sendo salvo. Deve retorna um objeto que será incluído no JSON quando for salvo o conteúdo do editor.
- constructor: o construtor recebe um objeto data, sendo este o conteúdo do bloco previamente carregado(o campo data dentro do json), um objeto config, com as configurações passadas na configuração do editor e um objeto api, para interagir com editor se necessário.
Veja o código do bloco.
import './index.css';
export default class Blockquote {
constructor({data, config, api, readOnly}){
this.data = data;
this.wrapper = undefined;
}
static get toolbox() {
return {
title: 'Blockquote',
icon: `<?xml version="1.0" ?><svg width="32" height="32" viewBox="0 0 1792 1792" width="1792" xmlns="http://www.w3.org/2000/svg"><path d="M832 960v384q0 80-56 136t-136 56h-384q-80 0-136-56t-56-136v-704q0-104 40.5-198.5t109.5-163.5 163.5-109.5 198.5-40.5h64q26 0 45 19t19 45v128q0 26-19 45t-45 19h-64q-106 0-181 75t-75 181v32q0 40 28 68t68 28h224q80 0 136 56t56 136zm896 0v384q0 80-56 136t-136 56h-384q-80 0-136-56t-56-136v-704q0-104 40.5-198.5t109.5-163.5 163.5-109.5 198.5-40.5h64q26 0 45 19t19 45v128q0 26-19 45t-45 19h-64q-106 0-181 75t-75 181v32q0 40 28 68t68 28h224q80 0 136 56t56 136z"/></svg>`
};
}
render(){
this.wrapper = document.createElement('figure');
this.wrapper.classList.add('blockquote-wrapper');
let blockquote = document.createElement('blockquote');
blockquote.classList.add('blockquote-blockquote');
blockquote.contentEditable = true;
this.wrapper.appendChild(blockquote);
let figcaption = document.createElement('figcaption');
let cite = document.createElement('cite');
cite.contentEditable = true;
cite.classList.add('blockquote-cite');
figcaption.appendChild(cite)
this.wrapper.appendChild(figcaption);
cite.innerHTML= this.data.cite ?? '';
blockquote.innerHTML = this.data.blockquote ?? '';
return this.wrapper;
}
save(blockContent){
return {
cite: blockContent.querySelector("cite").innerHTML,
blockquote: blockContent.querySelector("blockquote").innerHTML
}
}
}
Começamos importando o css com as classes que iremos utilizar na linha 1. Na linha 5 temos o construtor que recebe o data(os dados previamente definidos para o bloco) que atribuímos para uma propriedade da nossa classe, e inicializamos a propriedade wrapper para undefined, esta propriedade vai ser o elemento pai de todo o bloco, e iremos criá-lo no render.
Na linha 11 criamos o método toolbox, responsável por retornar um objeto, com as propriedades title e icon, sendo respectivamente o título do bloco e o ícone svg que irá aparecer no menu de inserir bloco.
Na linha 18 criamos o método render, como já mencionado ele é responsável pela exibição do bloco no editor, então é nele que criamos os elementos HTML que serão visualizados. Aqui criamos um elemento figure
como elemento pai de todos. Criamos um elemento blockquote
na linha 22 e adicionamos uma classe para ele, e configuramos o atributo contentEditable
para true, permitindo assim a modificação de seu conteúdo pelo usuário. Então adicionamos o elemento blockquote ao figure.
Na linha 27 criamos um elemente figcaption
logo após um elemento cite
. No elemento cite também configuramos o atributo contentEditable
para true e adicionamos uma classe. Então adicionamos o elemento cite no figcaption e o figcaption no figure, nosso elemento raiz.
Nas linhas 34 e 35 atribuímos para innerHTML dos elementos blockquote e cite os respectivos conteúdos passados pelo atributo data recebido no construtor, se não tiver sido passado atribuímos uma string em branco. E por fim, na linha 37 retornamos o elemento pai/raiz.
Na linha 41 criamos o método save
, este método deve retornar o conteúdo do campo data do json, e recebe como parâmetro o próprio elemento HTML raiz do bloco, então apenas utilizamos o método querySelector para obter o conteúdo dos elementos blockquote e cite, para retornar um objeto com estes dois parâmetros.
E o index.css
.blockquote-wrapper{
display: block;
margin-left: 100px;
border-left:5px solid #CCC;
background: #eee;
padding:10px;
}
.blockquote-cite{
display: block;
text-align: right;
margin-top:10px;
}
.blockquote-blockquote{
display: block;
text-align: justify;
margin:0;
}
Criando um parse em PHP
Criar um parser para o JSON gerado pelo editor não é nenhum mistério. Vamos a um exemplo então, tentando deixar ele um pouco flexível. Primeiro vamos criar uma interface para criar uma classe para fazer o parser de cada bloco. Sendo que ela terá um método que recebe como array o conteúdo do campo data do bloco e retorna o HTML correspondente. Cada uma das classes é bastante simples.
interface BlockParser
{
public function parse(array $data): string;
}
Para o Header:
class HeaderParser implements BlockParser
{
public function parse(array $data): string
{
$level = $data['level'];
$text = $data['text'];
return "<h$level>$text</h$level>";
}
}
Para o paragrafo:
class ParagraphParser implements BlockParser
{
public function parse(array $data): string
{
$text = $data['text'];
return "<p>$text</p>";
}
}
Para a lista:
class ListParser implements BlockParser
{
public function parse(array $data): string
{
$tag = $data['style'] == 'ordered' ? 'ol' : 'ul';
$content = "<$tag>";
$content .= array_reduce($data['items'], function($carry, $item, ){
return $carry . "<li>$item</li>";
},'');
$content .= "</$tag>";
return $content;
}
}
Para a imagem:
class ImageParser implements BlockParser
{
public function parse(array $data): string
{
$url = $data['file']['url'];
$caption = $data['caption'];
return <<<"IMG"
<figure>
<img src="$url">
<figcaption>$caption</figcaption>
</figure>
IMG;
}
}
E para nosso blockquote
class BlockquoteParser implements BlockParser
{
public function parse(array $data): string
{
$cite = $data['cite'];
$blockquote = $data['blockquote'];
return <<<"BLOCKQUOTE"
<figure>
<blockquote>$blockquote</blockquote>
<figcaption><cite>$cite</cite></figcaption>
</figure>
BLOCKQUOTE;
}
}
Então criamos a classe de parse propriamente dita:
use ParserBlock\Blocks\BlockParser;
class ParserHtml
{
private array $blocksParse = [];
public function addBlockParser(string $blockName, BlockParser $blockParser): void
{
$this->blocksParser[$blockName] = $blockParser;
}
public function parse(string $json): string
{
$data = json_decode($json, true);
$content = '';
foreach($data['blocks'] as $block){
if(array_key_exists($block['type'], $this->blocksParser)){
$parser = $this->blocksParser[ $block['type'] ];
$content .= $parser->parse($block['data']);
}
}
return $content;
}
}
Esta classe possui um atributo blocksParse
que é um array associativo, onde iremos armazenar os parsers dos blocos, sendo a chave de cada um, o tipo de bloco que ele faz a conversão. Para adicionar os parsers a este array criamos um método chamado addBlockParser na linha 7 que recebe um nome do tipo de bloco e o seu conversor.
Na linha 12 criamos o método de conversão. Nele recebemos um json em formato de string e o convertemos para um array utilizando json_decode. Logo após percorremos todos os blocos com um laço foreach e verificamos se o tipo do bloco está no array de parsers, se sim pegamos o objeto conversor e chamamos o método parse
dele passando o data do bloco. Então concatenamos o retorno(o bloco convertido para HTML) em uma string. Por fim, na linha 24 retornamos todo o conteúdo.
Para utilizar podemos
$parser = new ParserHtml();
$parser->addBlockParser('header', new HeaderParser());
$parser->addBlockParser('paragraph', new ParagraphParser());
$parser->addBlockParser('list', new ListParser());
$parser->addBlockParser('image', new ImageParser());
$parser->addBlockParser('blockquote', new BlockquoteParser());
$json = file_get_contents('content.json');
$content = $parser->parse($json);
echo $content;
Um json contendo as seguintes informações:
{
"time": 1662748019668,
"blocks": [
{
"id": "qUWmrdH_so",
"type": "header",
"data": {
"text": "Titulo",
"level": 2
}
},
{
"id": "aEgHdx5c7p",
"type": "paragraph",
"data": {
"text": "Um paragrafo"
}
},
{
"id": "myTFgyP1VF",
"type": "list",
"data": {
"style": "ordered",
"items": [
"item 1",
"item 2"
]
}
},
{
"id": "EyguSngZeS",
"type": "image",
"data": {
"file": {
"url": "http://localhost:8000/images/20220909032625.jpg"
},
"caption": "Uma imagem",
"withBorder": false,
"stretched": false,
"withBackground": false
}
},
{
"id": "wFtuLDOFbh",
"type": "blockquote",
"data": {
"cite": "Um autor",
"blockquote": "Uma citação"
}
}
],
"version": "2.25.0"
}
Irá ser convertido para o seguinte HTML
<h2>Titulo</h2>
<p>Um paragrafo</p>
<ol>
<li>item 1</li>
<li>item 2</li>
</ol>
<figure>
<img src="http://localhost:8000/images/20220909032625.jpg">
<figcaption>Uma imagem</figcaption>
</figure>
<figure>
<blockquote>Uma citação</blockquote>
<figcaption><cite>Um autor</cite></figcaption>
</figure>
Veja os códigos de exemplo.
Bom era isso, uma pequena introdução sobre o Editor.js, ele é bastante flexível, e como ele não gera marcações HTML e sim JSON nos permite ter mais flexibilidade com as marcações geradas, com um pouco mais de trabalho é claro, mas nada vem de graça.
Espero que tenha dado para ter uma noção inicial do editor, como sempre mais informações consulte a documentação.
T++