Construindo um Editor de Rich Text (WYSIWYG)
Publicados: 2022-03-10Nos ú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.
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.
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 ecaption
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 (chamadosanchor
efocus
) que representam um intervalo de texto dentro do documento. Esse conceito vem da API de seleção da Web, onde aanchor
é onde a seleção do usuário começou e ofocus
é 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
:
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
com dependências SlateJS adicionadas a ele. Estamos construindo a interface do usuário do aplicativo usando componentes do create-react-app
. Vamos começar!react-bootstrap
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 umadiv
por enquanto. - Atualize o componente
App.js
para manter o documento em seu estado inicializado em nossoExampleDocument
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
nesta fase tem a seguinte aparência:Editor.js
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.
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
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 }, ], },
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.
Aqui estão as três principais funcionalidades que precisamos que a barra de ferramentas suporte:
- 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.
- 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.
- 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.
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 <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} />
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 baixoat
função dematch
de correspondência de local fornecida. Este parâmetro (definido comohighest
) 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> .... );
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 }, ], }, ... }
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> );
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:
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
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.Transform.insertNodes
# 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
.
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.
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", }); ...
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.
As etapas da lógica para habilitar esse comportamento seriam:
- À 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.
- 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.
- 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 aunit
como parâmetro para que possamos solicitar o caractere/palavra/bloco, etc., antes que olocation
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.
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
.
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.
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.
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 (vejaExampleDocument
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: "" }], }, ];
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.
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 -
- 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. - Enviamos a solicitação ao servidor para fazer upload da imagem.
- 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.
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 usareditor.selection
, não podemos usarpreviousSelection
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.
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.