Construindo um Editor de Rich Text (WYSIWYG)

Publicados: 2022-03-10
Resumo rápido ↬ Neste artigo, aprenderemos como construir um editor WYSIWYG/Rich-Text que suporta rich text, imagens, links e alguns recursos diferenciados de aplicativos de processamento de texto. Usaremos o SlateJS para construir o shell do editor e, em seguida, adicionar uma barra de ferramentas e configurações personalizadas. O código do aplicativo está disponível no GitHub para referência.

Nos últimos anos, o campo da Criação e Representação de Conteúdos em Plataformas Digitais sofreu uma grande disrupção. O sucesso generalizado de produtos como Quip, Google Docs e Dropbox Paper mostrou como as empresas estão correndo para construir a melhor experiência para criadores de conteúdo no domínio corporativo e tentando encontrar maneiras inovadoras de quebrar os moldes tradicionais de como o conteúdo é compartilhado e consumido. Aproveitando o alcance massivo das plataformas de mídia social, há uma nova onda de criadores de conteúdo independentes usando plataformas como o Medium para criar conteúdo e compartilhá-lo com seu público.

Como tantas pessoas de diferentes profissões e origens tentam criar conteúdo nesses produtos, é importante que esses produtos forneçam uma experiência de criação de conteúdo eficiente e contínua e tenham equipes de designers e engenheiros que desenvolvam algum nível de conhecimento de domínio ao longo do tempo neste espaço . Com este artigo, tentamos não apenas estabelecer as bases da construção de um editor, mas também dar aos leitores um vislumbre de como pequenas funcionalidades quando reunidas podem criar uma ótima experiência de usuário para um criador de conteúdo.

Entendendo a estrutura do documento

Antes de mergulharmos na construção do editor, vamos ver como um documento é estruturado para um Editor de Rich Text e quais são os diferentes tipos de estruturas de dados envolvidos.

Nós de documento

Os nós de documento são usados ​​para representar o conteúdo do documento. Os tipos comuns de nós que um documento rich-text pode conter são parágrafos, títulos, imagens, vídeos, blocos de código e aspas. Alguns deles podem conter outros nós como filhos dentro deles (por exemplo, nós de parágrafo contêm nós de texto dentro deles). Os nós também contêm quaisquer propriedades específicas do objeto que representam que são necessárias para renderizar esses nós dentro do editor. (por exemplo, nós de imagem contêm uma propriedade src de imagem, blocos de código podem conter uma propriedade de language e assim por diante).

Existem basicamente dois tipos de nós que representam como eles devem ser renderizados -

  • Nós de bloco (análogo ao conceito HTML de elementos de nível de bloco) que são renderizados em uma nova linha e ocupam a largura disponível. Os nós de bloco podem conter outros nós de bloco ou nós em linha dentro deles. Uma observação aqui é que os nós de nível superior de um documento sempre seriam nós de bloco.
  • Nós Inline (análogo ao conceito HTML de elementos Inline) que começam a renderizar na mesma linha do nó anterior. Existem algumas diferenças em como os elementos embutidos são representados em diferentes bibliotecas de edição. O SlateJS permite que os elementos inline sejam os próprios nós. O DraftJS, outra biblioteca popular de edição de Rich Text, permite que você use o conceito de Entidades para renderizar elementos inline. Links e Imagens Inline são exemplos de nós Inline.
  • Void Nodes — SlateJS também permite essa terceira categoria de nós que usaremos posteriormente neste artigo para renderizar mídia.

Se você quiser saber mais sobre essas categorias, a documentação do SlateJS sobre Nodes é um bom lugar para começar.

Mais depois do salto! Continue lendo abaixo ↓

Atributos

Semelhante ao conceito de atributos do HTML, os atributos em um documento Rich Text são usados ​​para representar propriedades sem conteúdo de um nó ou de seus filhos. Por exemplo, um nó de texto pode ter atributos de estilo de caractere que nos informam se o texto está em negrito/itálico/sublinhado e assim por diante. Embora este artigo represente os cabeçalhos como nós próprios, outra maneira de representá-los pode ser que os nós tenham estilos de parágrafo ( paragraph & h1-h6 ) como atributos neles.

A imagem abaixo fornece um exemplo de como a estrutura de um documento (em JSON) é descrita em um nível mais granular usando nós e atributos destacando alguns dos elementos na estrutura à esquerda.

Imagem mostrando um documento de exemplo dentro do editor com sua representação de estrutura à esquerda
Documento de exemplo e sua representação estrutural. (Visualização grande)

Algumas das coisas que vale a pena chamar aqui com a estrutura são:

  • Os nós de texto são representados como {text: 'text content'}
  • As propriedades dos nós são armazenadas diretamente no nó (por exemplo, url para links e caption para imagens)
  • A representação específica do SlateJS de atributos de texto quebra os nós de texto para serem seus próprios nós se o estilo do caractere for alterado. Portanto, o texto ' Duis aute irure dolor ' é um nó de texto próprio com bold: true definido nele. O mesmo acontece com o texto em itálico, sublinhado e estilo de código neste documento.

Locais e seleção

Ao construir um editor de rich text, é crucial ter uma compreensão de como a parte mais granular de um documento (digamos, um caractere) pode ser representada com algum tipo de coordenadas. Isso nos ajuda a navegar na estrutura do documento em tempo de execução para entender onde estamos na hierarquia do documento. Mais importante ainda, os objetos de localização nos dão uma maneira de representar a seleção do usuário que é amplamente usada para personalizar a experiência do usuário do editor em tempo real. Usaremos a seleção para construir nossa barra de ferramentas mais adiante neste artigo. Exemplos destes podem ser:

  • O cursor do usuário está dentro de um link, talvez devêssemos mostrar a eles um menu para editar/remover o link?
  • O usuário selecionou uma imagem? Talvez demos a eles um menu para redimensionar a imagem.
  • Se o usuário selecionar determinado texto e clicar no botão DELETE, determinamos qual foi o texto selecionado do usuário e o removemos do documento.

O documento do SlateJS sobre Localização explica essas estruturas de dados extensivamente, mas as examinamos aqui rapidamente, pois usamos esses termos em diferentes instâncias do artigo e mostramos um exemplo no diagrama a seguir.

  • Caminho
    Representado por uma matriz de números, um caminho é o caminho para chegar a um nó no documento. Por exemplo, um caminho [2,3] representa o 3º nó filho do 2º nó no documento.
  • Apontar
    Localização mais granular do conteúdo representado por caminho + deslocamento. Por exemplo, um ponto de {path: [2,3], offset: 14} representa o 14º caractere do 3º nó filho dentro do 2º nó do documento.
  • Variedade
    Um par de pontos (chamados anchor e focus ) que representam um intervalo de texto dentro do documento. Esse conceito vem da API de seleção da Web, onde a anchor é onde a seleção do usuário começou e o focus é onde ela terminou. Um intervalo/seleção recolhido denota onde os pontos de ancoragem e foco são os mesmos (pense em um cursor piscando em uma entrada de texto, por exemplo).

Como exemplo, digamos que a seleção do usuário em nosso exemplo de documento acima seja ipsum :

Imagem com o texto `ipsum` selecionado no editor
O usuário seleciona a palavra ipsum . (Visualização grande)

A seleção do usuário pode ser representada como:

 { anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`

Configurando o Editor

Nesta seção, vamos configurar o aplicativo e obter um editor de rich-text básico com SlateJS. O aplicativo padrão seria create-react-app com dependências SlateJS adicionadas a ele. Estamos construindo a interface do usuário do aplicativo usando componentes do react-bootstrap . Vamos começar!

Crie uma pasta chamada wysiwyg-editor e execute o comando abaixo de dentro do diretório para configurar o aplicativo react. Em seguida, executamos um comando yarn start que deve ativar o servidor web local (porta padronizada para 3000) e mostrar uma tela de boas-vindas do React.

 npx create-react-app . yarn start

Em seguida, passamos para adicionar as dependências do SlateJS ao aplicativo.

 yarn add slate slate-react

slate é o pacote principal do SlateJS e slate-react react inclui o conjunto de componentes React que usaremos para renderizar editores Slate. O SlateJS expõe mais alguns pacotes organizados por funcionalidade que se pode considerar adicionar ao seu editor.

Primeiro, criamos uma pasta utils que contém todos os módulos utilitários que criamos neste aplicativo. Começamos criando um ExampleDocument.js que retorna uma estrutura básica de documento que contém um parágrafo com algum texto. Este módulo tem a seguinte aparência:

 const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;

Agora adicionamos uma pasta chamada components que conterá todos os nossos componentes React e fazemos o seguinte:

  • Adicione nosso primeiro componente React Editor.js a ele. Ele retorna apenas uma div por enquanto.
  • Atualize o componente App.js para manter o documento em seu estado inicializado em nosso ExampleDocument acima.
  • Renderize o Editor dentro do aplicativo e passe o estado do documento e um manipulador onChange para o Editor para que nosso estado do documento seja atualizado à medida que o usuário o atualiza.
  • Usamos os componentes Nav do React bootstrap para adicionar uma barra de navegação ao aplicativo também.

O componente App.js agora se parece com o abaixo:

 import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );

Dentro do componente Editor, instanciamos o editor SlateJS e o mantemos dentro de um useMemo para que o objeto não seja alterado entre as novas renderizações.

 // dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);

createEditor nos fornece a instância do editor SlateJS que usamos extensivamente por meio do aplicativo para acessar seleções, executar transformações de dados e assim por diante. withReact é um plugin SlateJS que adiciona comportamentos React e DOM ao objeto do editor. SlateJS Plugins são funções Javascript que recebem o objeto editor e anexam alguma configuração a ele. Isso permite que os desenvolvedores da Web adicionem configurações à instância do editor SlateJS de uma maneira que pode ser composta.

Agora importamos e renderizamos os componentes <Slate /> e <Editable /> do SlateJS com a prop de documento que obtemos do App.js. Slate expõe vários contextos React que usamos para acessar o código do aplicativo. Editable é o componente que renderiza a hierarquia do documento para edição. No geral, o módulo Editor.js nesta fase tem a seguinte aparência:

 import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }

Neste ponto, temos os componentes React necessários adicionados e o editor preenchido com um documento de exemplo. Nosso Editor deve estar configurado agora, permitindo que digitemos e alteremos o conteúdo em tempo real — como no screencast abaixo.

Configuração básica do editor em ação

Agora, vamos para a próxima seção onde configuramos o editor para renderizar estilos de caracteres e nós de parágrafo.

RENDIÇÃO DE TEXTO PERSONALIZADA E UMA BARRA DE FERRAMENTAS

Nós de estilo de parágrafo

Atualmente, nosso editor usa a renderização padrão do SlateJS para quaisquer novos tipos de nós que possamos adicionar ao documento. Nesta seção, queremos ser capazes de renderizar os nós de cabeçalho. Para poder fazer isso, fornecemos uma função renderElement prop para os componentes do Slate. Essa função é chamada pelo Slate em tempo de execução quando tenta percorrer a árvore do documento e renderizar cada nó. A função renderElement recebe três parâmetros —

  • attributes
    SlateJS específico que deve ser aplicado ao elemento DOM de nível superior que está sendo retornado desta função.
  • element
    O próprio objeto de nó como ele existe na estrutura do documento
  • children
    Os filhos deste nó conforme definido na estrutura do documento.

Adicionamos nossa implementação de renderElement a um gancho chamado useEditorConfig onde adicionaremos mais configurações de editor à medida que avançamos. Em seguida, usamos o gancho na instância do editor dentro Editor.js .

 import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }

Como essa função nos dá acesso ao element (que é o próprio nó), podemos personalizar renderElement para implementar uma renderização mais personalizada que faz mais do que apenas verificar element.type . Por exemplo, você pode ter um nó de imagem que tenha uma propriedade isInline que podemos usar para retornar uma estrutura DOM diferente que nos ajude a renderizar imagens em linha em relação a imagens de bloco.

Agora atualizamos o componente Editor para usar este gancho conforme abaixo:

 const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );

Com a renderização personalizada em vigor, atualizamos o ExampleDocument para incluir nossos novos tipos de nó e verificamos se eles são renderizados corretamente dentro do editor.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Imagem mostrando diferentes títulos e nós de parágrafo renderizados no editor
Cabeçalhos e nós de parágrafo no Editor. (Visualização grande)

Estilos de caracteres

Semelhante ao renderElement , o SlateJS fornece uma função prop chamada renderLeaf que pode ser usada para personalizar a renderização dos nós de texto ( Leaf referindo-se aos nós de texto que são as folhas/nós de nível mais baixo da árvore do documento). Seguindo o exemplo de renderElement , escrevemos uma implementação para renderLeaf .

 export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }

Uma observação importante da implementação acima é que ela nos permite respeitar a semântica HTML para estilos de caracteres. Como renderLeaf nos dá acesso à própria leaf do nó de texto, podemos personalizar a função para implementar uma renderização mais personalizada. Por exemplo, você pode ter uma maneira de permitir que os usuários escolham um highlightColor para o texto e marquem essa propriedade de folha aqui para anexar os respectivos estilos.

Agora atualizamos o componente Editor para usar o acima, o ExampleDocument para ter alguns nós de texto no parágrafo com combinações desses estilos e verificamos se eles são renderizados conforme o esperado no Editor com as tags semânticas que usamos.

 # src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
 # src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], }, 
Estilos de caracteres na interface do usuário e como eles são renderizados na árvore DOM
Estilos de caracteres na interface do usuário e como eles são renderizados na árvore DOM. (Visualização grande)

Adicionando uma barra de ferramentas

Vamos começar adicionando um novo componente Toolbar.js ao qual adicionamos alguns botões para estilos de caracteres e uma lista suspensa para estilos de parágrafo e os conectamos mais tarde na seção.

 const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }

Abstraímos os botões para o componente ToolbarButton que é um wrapper em torno do componente React Bootstrap Button. Em seguida, renderizamos a barra de ferramentas acima do componente Editable inside Editor e verificamos se a barra de ferramentas aparece no aplicativo.

Imagem mostrando a barra de ferramentas com botões renderizados acima do editor
Barra de ferramentas com botões (visualização grande)

Aqui estão as três principais funcionalidades que precisamos que a barra de ferramentas suporte:

  1. Quando o cursor do usuário está em um determinado ponto do documento e ele clica em um dos botões de estilo de caractere, precisamos alternar o estilo para o texto que ele deve digitar em seguida.
  2. Quando o usuário seleciona um intervalo de texto e clica em um dos botões de estilo de caractere, precisamos alternar o estilo para essa seção específica.
  3. Quando o usuário seleciona um intervalo de texto, queremos atualizar a lista suspensa de estilo de parágrafo para refletir o tipo de parágrafo da seleção. Se eles selecionarem um valor diferente da seleção, queremos atualizar o estilo de parágrafo de toda a seleção para ser o que eles selecionaram.

Vamos ver como essas funcionalidades funcionam no Editor antes de começarmos a implementá-las.

Comportamento de alternância de estilos de caractere

Ouvindo a Seleção

A coisa mais importante que a Barra de Ferramentas precisa para poder executar as funções acima é o estado Seleção do documento. Ao escrever este artigo, o SlateJS não expõe um método onSelectionChange que poderia nos fornecer o estado de seleção mais recente do documento. No entanto, conforme a seleção muda no editor, o SlateJS chama o método onChange , mesmo que o conteúdo do documento não tenha sido alterado. Usamos isso como uma forma de ser notificado da mudança de seleção e armazená-la no estado do componente Editor . Abstraímos isso para um hook useSelection onde poderíamos fazer uma atualização mais otimizada do estado de seleção. Isso é importante, pois a seleção é uma propriedade que muda com bastante frequência para uma instância do Editor WYSIWYG.

 import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }

Usamos este gancho dentro do componente Editor como abaixo e passamos a seleção para o componente Toolbar.

 const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...

Consideração de desempenho

Em um aplicativo em que temos uma base de código do Editor muito maior com muito mais funcionalidades, é importante armazenar e ouvir as alterações de seleção de maneira eficiente (como usar alguma biblioteca de gerenciamento de estado), pois os componentes que ouvem as alterações de seleção provavelmente também serão renderizados muitas vezes. Uma maneira de fazer isso é ter seletores otimizados no topo do estado de Seleção que contêm informações de seleção específicas. Por exemplo, um editor pode querer renderizar um menu de redimensionamento de imagem quando uma Imagem é selecionada. Nesse caso, pode ser útil ter um seletor isImageSelected calculado a partir do estado de seleção do editor e o menu Image seria renderizado novamente somente quando o valor desse seletor mudar. A Reselect do Redux é uma dessas bibliotecas que permite a construção de seletores.

Nós não usamos a selection dentro da barra de ferramentas até mais tarde, mas passá-la como um prop faz com que a barra de ferramentas seja renderizada novamente toda vez que a seleção for alterada no Editor. Fazemos isso porque não podemos confiar apenas na alteração do conteúdo do documento para acionar uma nova renderização na hierarquia ( App -> Editor -> Toolbar ), pois os usuários podem continuar clicando no documento, alterando a seleção, mas nunca alterando o conteúdo do documento em si.

Alternando Estilos de Caracteres

Agora passamos para obter quais são os estilos de caracteres ativos do SlateJS e usá-los dentro do Editor. Vamos adicionar um novo módulo JS EditorUtils que hospedará todas as funções util que construímos daqui para frente para obter/fazer coisas com SlateJS. Nossa primeira função no módulo é getActiveStyles que fornece um Set de estilos ativos no editor. Também adicionamos uma função para alternar um estilo na função do editor — toggleStyle :

 # src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }

Ambas as funções usam o objeto do editor , que é a instância do Slate, como parâmetro, assim como muitas funções úteis que adicionamos posteriormente no artigo. e removemos essas marcas. Importamos essas funções util dentro da Barra de Ferramentas e as conectamos aos botões que adicionamos anteriormente.

 # src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>

useEditor é um gancho Slate que nos dá acesso à instância Slate a partir do contexto onde ela foi anexada pelo componente &lt;Slate> mais acima na hierarquia de renderização.

Alguém pode se perguntar por que usamos onMouseDown aqui em vez de onClick ? Há um problema aberto no Github sobre como o Slate torna a selection null quando o editor perde o foco de alguma forma. Portanto, se anexarmos manipuladores onClick aos nossos botões da barra de ferramentas, a selection se tornará null e os usuários perderão a posição do cursor tentando alternar um estilo que não é uma ótima experiência. Em vez disso, alternamos o estilo anexando um evento onMouseDown que impede que a seleção seja redefinida. Outra maneira de fazer isso é acompanhar a seleção por conta própria para saber qual foi a última seleção e usá-la para alternar os estilos. Introduzimos o conceito de previousSelection posteriormente neste artigo, mas para resolver um problema diferente.

SlateJS nos permite configurar manipuladores de eventos no Editor. Usamos isso para conectar atalhos de teclado para alternar os estilos de caracteres. Para fazer isso, adicionamos um objeto KeyBindings dentro useEditorConfig onde expomos um manipulador de eventos onKeyDown anexado ao componente Editable . Usamos o utilitário is-hotkey para determinar a combinação de teclas e alternar o estilo correspondente.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> 
Estilos de caracteres alternados usando atalhos de teclado.

Fazendo funcionar a lista suspensa de estilo de parágrafo

Vamos seguir em frente para fazer o menu suspenso Estilos de parágrafo funcionar. Semelhante a como as listas suspensas de estilo de parágrafo funcionam em aplicativos populares de processamento de texto, como MS Word ou Google Docs, queremos que os estilos dos blocos de nível superior na seleção do usuário sejam refletidos na lista suspensa. Se houver um único estilo consistente na seleção, atualizaremos o valor da lista suspensa para ser esse. Se houver vários deles, definimos o valor da lista suspensa como 'Múltiplos'. Esse comportamento deve funcionar tanto para seleções recolhidas quanto expandidas.

Para implementar esse comportamento, precisamos encontrar os blocos de nível superior que abrangem a seleção do usuário. Para fazer isso, usamos o Editor.nodes do Slate — Uma função auxiliar comumente usada para procurar nós em uma árvore filtrada por diferentes opções.

 nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>

A função auxiliar usa uma instância do Editor e um objeto de options que é uma maneira de filtrar nós na árvore à medida que ela a percorre. A função retorna um gerador de NodeEntry . Um NodeEntry na terminologia Slate é uma tupla de um nó e o caminho para ele — [node, pathToNode] . As opções encontradas aqui estão disponíveis na maioria das funções auxiliares do Slate. Vamos ver o que cada um deles significa:

  • at
    Este pode ser um Caminho/Ponto/Intervalo que a função auxiliar usaria para reduzir o escopo da travessia da árvore. O padrão é editor.selection se não for fornecido. Também usamos o padrão para nosso caso de uso abaixo, pois estamos interessados ​​em nós dentro da seleção do usuário.
  • match
    Esta é uma função de correspondência que pode ser fornecida, que é chamada em cada nó e incluída se for uma correspondência. Usamos esse parâmetro em nossa implementação abaixo para filtrar apenas elementos de bloco.
  • mode
    Vamos as funções auxiliares saberem se estamos interessados ​​em todos os nós de nível mais alto ou de nível mais baixo at função de match de correspondência de local fornecida. Este parâmetro (definido como highest ) nos ajuda a escapar da tentativa de percorrer a árvore para cima para encontrar os nós de nível superior.
  • universal
    Sinalize para escolher entre correspondências totais ou parciais dos nós. (O problema do GitHub com a proposta para este sinalizador tem alguns exemplos explicando isso)
  • reverse
    Se a busca do nó deve ser na direção inversa dos pontos inicial e final do local passado.
  • voids
    Se a pesquisa deve filtrar apenas para elementos nulos.

O SlateJS expõe muitas funções auxiliares que permitem consultar nós de diferentes maneiras, percorrer a árvore, atualizar os nós ou seleções de maneiras complexas. Vale a pena explorar algumas dessas interfaces (listadas no final deste artigo) ao criar funcionalidades de edição complexas em cima do Slate.

Com esse histórico na função auxiliar, abaixo está uma implementação de getTextBlockStyle .

 # src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }

Consideração de desempenho

A implementação atual de Editor.nodes encontra todos os nós em toda a árvore em todos os níveis que estão dentro do intervalo do parâmetro at e, em seguida, executa filtros de correspondência nele (verifique nodeEntries e a filtragem posteriormente — source). Isso é bom para documentos menores. No entanto, para nosso caso de uso, se o usuário selecionou, digamos, 3 títulos e 2 parágrafos (cada parágrafo contendo, digamos, 10 nós de texto), ele percorrerá pelo menos 25 nós (3 + 2 + 2*10) e tentará executar filtros neles. Como já sabemos que estamos interessados ​​apenas em nós de nível superior, podemos encontrar índices de início e fim dos blocos de nível superior da seleção e nos iterarmos. Essa lógica percorreria apenas 3 entradas de nó (2 títulos e 1 parágrafo). O código para isso seria algo como abaixo:

 export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }

À medida que adicionamos mais funcionalidades a um Editor WYSIWYG e precisamos percorrer a árvore do documento com frequência, é importante pensar nas maneiras mais eficientes de fazê-lo para o caso de uso em questão, pois a API disponível ou os métodos auxiliares podem nem sempre ser os mais adequados. maneira eficiente de fazê-lo.

Uma vez implementado o getTextBlockStyle , a alternância do estilo do bloco é relativamente simples. Se o estilo atual não for o que o usuário selecionou na lista suspensa, alternamos o estilo para isso. Se já for o que o usuário selecionou, alternamos para que seja um parágrafo. Como estamos representando estilos de parágrafo como nós em nossa estrutura de documento, alternar um estilo de parágrafo significa essencialmente alterar a propriedade de type no nó. Usamos Transforms.setNodes fornecido pelo Slate para atualizar propriedades em nós.

A implementação do nosso toggleBlockType é a seguinte:

 # src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }

Por fim, atualizamos nosso menu suspenso Estilo de parágrafo para usar essas funções de utilitário.

 #src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... ); 
Selecionando vários tipos de bloco e alterando o tipo com o menu suspenso.

LINKS

Nesta seção, adicionaremos suporte para mostrar, adicionar, remover e alterar links. Também adicionaremos uma funcionalidade Link-Detector - bastante semelhante a como o Google Docs ou o MS Word, que verifica o texto digitado pelo usuário e verifica se há links nele. Se houver, eles serão convertidos em objetos de link para que o usuário não precise usar os botões da barra de ferramentas para fazer isso sozinho.

Renderizando Links

Em nosso editor, vamos implementar links como nós inline com SlateJS. Atualizamos nossa configuração do editor para sinalizar links como nós inline para SlateJS e também fornecemos um componente para renderizar para que o Slate saiba como renderizar os nós de link.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
 # src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }

We then add a link node to our ExampleDocument and verify that it renders correctly (including a case for character styles inside a link) in the Editor.

 # src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

Adding A Link Button To The Toolbar

Let's add a Link Button to the toolbar that enables the user to do the following:

  • Selecting some text and clicking on the button converts that text into a link
  • Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
  • If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.

To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above helper function from SlateJS.

 # src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }

Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.

 # src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> ); 
Link button in Toolbar becomes active if selection is inside a link.

To toggle links in the editor, we add a util function toggleLinkAtSelection . Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (Visualização grande)

If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection has the below implementation to insert a new link at an expanded selection.

 # src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }

If the selection is collapsed, we insert a new node there with Transform.insertNodes that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.

 # src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} /> 

Link Editor Menu

So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor component and rendering it whenever the user selection is inside a link.

# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
 # src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>

Como estamos renderizando o LinkEditor fora do editor, precisamos de uma maneira de informar ao LinkEditor onde o link está localizado na árvore DOM para que ele possa se renderizar próximo ao editor. A maneira como fazemos isso é usar a API React do Slate para encontrar o nó DOM correspondente ao nó do link na seleção. E então usamos getBoundingClientRect() para encontrar os limites do elemento DOM do link e os limites do componente do editor e calcular o top e a left para o editor de links. As atualizações de código para Editor e LinkEditor são as seguintes —

 # src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
 # src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }

O SlateJS mantém internamente mapas de nós para seus respectivos elementos DOM. Acessamos esse mapa e encontramos o elemento DOM do link usando ReactEditor.toDOMNode .

A seleção dentro de um link mostra o popover do editor de links.

Como visto no vídeo acima, quando um link é inserido e não possui uma URL, porque a seleção está dentro do link, ele abre o editor de links dando assim ao usuário uma forma de digitar uma URL para o link recém inserido e portanto, fecha o ciclo na experiência do usuário.

Agora adicionamos um elemento de entrada e um botão ao LinkEditor que permite ao usuário digitar uma URL e aplicá-la ao nó do link. Usamos o pacote isUrl para validação de URL.

 # src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );

Com os elementos do formulário conectados, vamos ver se o editor de links funciona conforme o esperado.

Editor perdendo a seleção ao clicar dentro do editor de links

Como vemos aqui no vídeo, quando o usuário tenta clicar na entrada, o editor de links desaparece. Isso ocorre porque quando renderizamos o editor de links fora do componente Editable , quando o usuário clica no elemento de entrada, o SlateJS pensa que o editor perdeu o foco e redefine a selection para ser null , o que remove o LinkEditor pois isLinkActiveAtSelection não é mais true . Há um problema aberto no GitHub que fala sobre esse comportamento do Slate. Uma maneira de resolver isso é rastrear a seleção anterior de um usuário à medida que ela muda e quando o editor perde o foco, podemos olhar para a seleção anterior e ainda mostrar um menu do editor de links se a seleção anterior tiver um link nele. Vamos atualizar o gancho useSelection para lembrar a seleção anterior e devolvê-la ao componente Editor.

 # src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }

Em seguida, atualizamos a lógica no componente Editor para mostrar o menu de links, mesmo que a seleção anterior tenha um link nele.

 # src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );

Em seguida, atualizamos o LinkEditor para usar selectionForLink para procurar o nó do link, renderizar abaixo dele e atualizar sua URL.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Editando link usando o componente LinkEditor.

Detectando links em texto

A maioria dos aplicativos de processamento de texto identifica e converte links dentro de texto em objetos de link. Vamos ver como isso funcionaria no editor antes de começarmos a construí-lo.

Links sendo detectados conforme o usuário os digita.

As etapas da lógica para habilitar esse comportamento seriam:

  1. À medida que o documento muda com a digitação do usuário, encontre o último caractere inserido pelo usuário. Se esse caractere é um espaço, sabemos que deve haver uma palavra que pode ter vindo antes dele.
  2. Se o último caractere for um espaço, marcamos isso como o limite final da palavra que veio antes dele. Em seguida, percorremos de volta caractere por caractere dentro do nó de texto para descobrir onde essa palavra começou. Durante essa travessia, temos que ter cuidado para não passar da borda do início do nó para o nó anterior.
  3. Depois de encontrarmos os limites inicial e final da palavra antes, verificamos a string da palavra e vemos se era uma URL. Se foi, nós o convertemos em um nó de link.

Nossa lógica vive em uma função identifyLinksInTextIfAny que vive em EditorUtils e é chamada dentro do componente onChange in Editor .

 # src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );

Aqui está o identifyLinksInTextIfAny com a lógica da Etapa 1 implementada:

 export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }

Existem duas funções auxiliares do SlateJS que facilitam as coisas aqui.

  • Editor.before — Nos dá o ponto antes de um determinado local. Ele usa a unit como parâmetro para que possamos solicitar o caractere/palavra/bloco, etc., antes que o location seja passado.
  • Editor.string — Obtém a string dentro de um intervalo.

Como exemplo, o diagrama abaixo explica quais são os valores dessas variáveis ​​quando o usuário insere um caractere 'E' e seu cursor está posicionado após ele.

Diagrama explicando para onde cursorPoint e startPointOfLastCharacter apontam após a etapa 1 com um exemplo
cursorPoint e startPointOfLastCharacter após a Etapa 1 com um texto de exemplo. (Visualização grande)

Se o texto 'ABCDE' fosse o primeiro nó de texto do primeiro parágrafo no documento, nossos valores de ponto seriam —

 cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}

Se o último caractere for um espaço, sabemos onde ele começou — startPointOfLastCharacter. Vamos para a etapa 2, onde retrocedemos caractere por caractere até encontrarmos outro espaço ou o início do próprio nó de texto.

 ... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);

Aqui está um diagrama que mostra para onde esses diferentes pontos apontam quando encontramos a última palavra inserida como ABCDE .

Diagrama explicando onde estão os diferentes pontos após a etapa 2 da detecção de link com um exemplo
Onde pontos diferentes estão após a etapa 2 da detecção de link com um exemplo. (Visualização grande)

Observe que start e end são os pontos antes e depois do espaço. Da mesma forma, startPointOfLastCharacter e cursorPoint são os pontos antes e depois do espaço que o usuário acabou de inserir. Portanto [end,startPointOfLastCharacter] nos dá a última palavra inserida.

Registramos o valor de lastWord no console e verificamos os valores conforme digitamos.

Logs do console verificando a última palavra conforme inserida pelo usuário após a lógica na Etapa 2.

Agora que deduzimos qual foi a última palavra que o usuário digitou, verificamos se realmente era uma URL e convertemos esse intervalo em um objeto de link. Essa conversão é semelhante à forma como o botão de link da barra de ferramentas converteu o texto selecionado de um usuário em um link.

 if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }

identifyLinksInTextIfAny é chamado dentro do onChange do Slate, então não gostaríamos de atualizar a estrutura do documento dentro do onChange . Portanto, colocamos essa atualização em nossa fila de tarefas com uma Promise.resolve().then(..) .

Vamos ver a lógica se unir em ação! Verificamos se inserimos links no final, no meio ou no início de um nó de texto.

Links sendo detectados enquanto o usuário os digita.

Com isso, encerramos as funcionalidades dos links no editor e passamos para as Imagens.

Manipulação de imagens

Nesta seção, nos concentramos em adicionar suporte para renderizar nós de imagem, adicionar novas imagens e atualizar legendas de imagem. As imagens, em nossa estrutura de documentos, seriam representadas como nós Void. Os nós Void no SlateJS (análogos aos elementos Void na especificação HTML) são tais que seu conteúdo não é texto editável. Isso nos permite renderizar imagens como vazios. Por causa da flexibilidade do Slate com renderização, ainda podemos renderizar nossos próprios elementos editáveis ​​dentro de elementos Void — o que faremos para edição de legendas de imagens. O SlateJS tem um exemplo que demonstra como você pode incorporar um Rich Text Editor inteiro dentro de um elemento Void.

Para renderizar imagens, configuramos o editor para tratar imagens como elementos Void e fornecemos uma implementação de renderização de como as imagens devem ser renderizadas. Adicionamos uma imagem ao nosso ExampleDocument e verificamos se ela é renderizada corretamente com a legenda.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }

Duas coisas para lembrar ao tentar renderizar nós nulos com SlateJS:

  • O elemento DOM raiz deve ter contentEditable={false} definido nele para que o SlateJS trate seu conteúdo assim. Sem isso, conforme você interage com o elemento void, o SlateJS pode tentar computar seleções etc. e quebrar como resultado.
  • Mesmo que os nós Void não tenham nós filhos (como nosso nó de imagem como exemplo), ainda precisamos renderizar children e fornecer um nó de texto vazio como filho (veja ExampleDocument abaixo) que é tratado como um ponto de seleção do Void elemento por SlateJS

Agora atualizamos o ExampleDocument para adicionar uma imagem e verificar se ela aparece com a legenda no editor.

 # src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ]; 
Imagem renderizada no Editor
Imagem renderizada no Editor. (Visualização grande)

Agora vamos nos concentrar na edição de legendas. A maneira como queremos que isso seja uma experiência perfeita para o usuário é que, quando ele clica na legenda, mostramos uma entrada de texto onde ele pode editar a legenda. Se eles clicarem fora da entrada ou pressionarem a tecla RETURN, tratamos isso como uma confirmação para aplicar a legenda. Em seguida, atualizamos a legenda no nó da imagem e voltamos a legenda para o modo de leitura. Vamos vê-lo em ação para termos uma ideia do que estamos construindo.

Edição de legenda de imagem em ação.

Vamos atualizar nosso componente Image para ter um estado para os modos de leitura e edição da legenda. Atualizamos o estado da legenda local à medida que o usuário a atualiza e quando ele clica fora ( onBlur ) ou pressiona RETURN ( onKeyDown ), aplicamos a legenda ao nó e alternamos para o modo de leitura novamente.

 const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...

Com isso, a funcionalidade de edição de legendas está completa. Agora passamos a adicionar uma maneira de os usuários fazerem upload de imagens para o editor. Vamos adicionar um botão da barra de ferramentas que permite aos usuários selecionar e fazer upload de uma imagem.

 # src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>

À medida que trabalhamos com uploads de imagem, o código pode crescer um pouco, então movemos o tratamento de upload de imagem para um hook useImageUploadHandler que fornece um retorno de chamada anexado ao elemento file-input. Discutiremos em breve por que ele precisa do estado previousSelection .

Antes de implementar useImageUploadHandler , vamos configurar o servidor para poder fazer upload de uma imagem. Configuramos um servidor Express e instalamos dois outros pacotes — cors e multer que tratam de uploads de arquivos para nós.

 yarn add express cors multer

Em seguida, adicionamos um script src/server.js que configura o servidor Express com cors e multer e expõe um endpoint /upload o qual faremos upload da imagem.

 # src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));

Agora que temos a configuração do servidor, podemos nos concentrar em lidar com o upload da imagem. Quando o usuário carrega uma imagem, pode levar alguns segundos até que a imagem seja carregada e tenhamos um URL para ela. No entanto, fazemos o que para dar um feedback imediato ao usuário de que o upload da imagem está em andamento para que ele saiba que a imagem está sendo inserida no editor. Aqui estão as etapas que implementamos para fazer esse comportamento funcionar -

  1. Depois que o usuário seleciona uma imagem, inserimos um nó de imagem na posição do cursor do usuário com um sinalizador isUploading definido para que possamos mostrar ao usuário um estado de carregamento.
  2. Enviamos a solicitação ao servidor para fazer upload da imagem.
  3. Assim que a solicitação for concluída e tivermos um URL de imagem, definimos isso na imagem e removemos o estado de carregamento.

Vamos começar com a primeira etapa onde inserimos o nó da imagem. Agora, a parte complicada aqui é que nos deparamos com o mesmo problema com a seleção que com o botão de link na barra de ferramentas. Assim que o usuário clica no botão Imagem na barra de ferramentas, o editor perde o foco e a seleção se torna null . Se tentarmos inserir uma imagem, não saberemos onde estava o cursor do usuário. O rastreamento previousSelection nos dá essa localização e a usamos para inserir o nó.

 # src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }

À medida que inserimos o novo nó de imagem, também atribuímos a ele um id de identificador usando o pacote uuid. Discutiremos na implementação da Etapa (3) por que precisamos disso. Agora atualizamos o componente de imagem para usar o sinalizador isUploading para mostrar um estado de carregamento.

 {!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}

Isso conclui a implementação da etapa 1. Vamos verificar se conseguimos selecionar uma imagem para upload, veja o nó da imagem sendo inserido com um indicador de carregamento onde foi inserido no documento.

Upload de imagem criando um nó de imagem com estado de carregamento.

Passando para a Etapa (2), usaremos a biblioteca axois para enviar uma solicitação ao servidor.

 export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }

Verificamos se o upload da imagem funciona e a imagem aparece na pasta public/photos do aplicativo. Agora que o upload da imagem está completo, vamos para a Etapa (3) onde queremos definir a URL na imagem na função resolve() da promessa axios. Poderíamos atualizar a imagem com Transforms.setNodes , mas temos um problema — não temos o caminho para o nó de imagem recém-inserido. Vamos ver quais são nossas opções para chegar a essa imagem —

  • Não podemos usar editor.selection já que a seleção deve estar no nó de imagem recém-inserido? Não podemos garantir isso, pois enquanto a imagem estava sendo carregada, o usuário pode ter clicado em outro lugar e a seleção pode ter sido alterada.
  • Que tal usar previousSelection que usamos para inserir o nó da imagem em primeiro lugar? Pela mesma razão que não podemos usar editor.selection , não podemos usar previousSelection pois pode ter mudado também.
  • O SlateJS possui um módulo Histórico que rastreia todas as alterações que ocorrem no documento. Poderíamos usar este módulo para pesquisar o histórico e encontrar o último nó de imagem inserido. Isso também não é totalmente confiável se levar mais tempo para o upload da imagem e o usuário inserir mais imagens em diferentes partes do documento antes da conclusão do primeiro upload.
  • Atualmente, a API do Transform.insertNodes não retorna nenhuma informação sobre os nós inseridos. Se pudesse retornar os caminhos para os nós inseridos, poderíamos usar isso para encontrar o nó de imagem preciso que devemos atualizar.

Como nenhuma das abordagens acima funciona, aplicamos um id ao nó de imagem inserido (na Etapa (1)) e usamos o mesmo id novamente para localizá-lo quando o upload da imagem for concluído. Com isso, nosso código para a Etapa (3) se parece com abaixo —

 axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });

Com a implementação de todas as três etapas concluídas, estamos prontos para testar o upload da imagem de ponta a ponta.

Upload de imagem funcionando de ponta a ponta

Com isso, finalizamos Imagens para nosso editor. Atualmente, mostramos um estado de carregamento do mesmo tamanho, independentemente da imagem. Isso pode ser uma experiência chocante para o usuário se o estado de carregamento for substituído por uma imagem drasticamente menor ou maior quando o upload for concluído. Um bom acompanhamento da experiência de upload é obter as dimensões da imagem antes do upload e mostrar um espaço reservado desse tamanho para que a transição seja perfeita. O gancho que adicionamos acima pode ser estendido para oferecer suporte a outros tipos de mídia, como vídeo ou documentos, e renderizar esses tipos de nós também.

Conclusão

Neste artigo, construímos um Editor WYSIWYG que possui um conjunto básico de funcionalidades e algumas microexperiências do usuário, como detecção de links, edição de links no local e edição de legendas de imagens que nos ajudaram a aprofundar o SlateJS e os conceitos de Rich Text Editing em em geral. Se esse espaço de problemas em torno da Edição de Rich Text ou Processamento de Texto lhe interessar, alguns dos problemas interessantes a serem seguidos podem ser:

  • Colaboração
  • Uma experiência de edição de texto mais rica que suporta alinhamentos de texto, imagens embutidas, copiar e colar, alterar fontes e cores de texto, etc.
  • Importação de formatos populares como documentos do Word e Markdown.

Se você quiser saber mais sobre o SlateJS, aqui estão alguns links que podem ser úteis.

  • Exemplos SlateJS
    Muitos exemplos que vão além do básico e constroem funcionalidades que normalmente são encontradas em Editores como Search & Highlight, Markdown Preview e Mentions.
  • Documentos da API
    Referência a muitas funções auxiliares expostas pelo SlateJS que se pode querer manter à mão ao tentar realizar consultas/transformações complexas em objetos SlateJS.

Por fim, o Slack Channel do SlateJS é uma comunidade muito ativa de desenvolvedores da Web que criam aplicativos de edição de Rich Text usando o SlateJS e um ótimo lugar para aprender mais sobre a biblioteca e obter ajuda, se necessário.